mirror of
https://github.com/element-hq/element-android
synced 2024-11-24 02:15:35 +03:00
Merge pull request #5084 from vector-im/feature/bma/location_crash
Fix location crash
This commit is contained in:
commit
91e444ca73
29 changed files with 573 additions and 317 deletions
1
changelog.d/5084.bugfix
Normal file
1
changelog.d/5084.bugfix
Normal file
|
@ -0,0 +1 @@
|
|||
Display static map images in the timeline and improve Location sharing feature
|
6
library/ui-styles/src/main/res/values-ldrtl/bools.xml
Normal file
6
library/ui-styles/src/main/res/values-ldrtl/bools.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<bool name="is_rtl">true</bool>
|
||||
|
||||
</resources>
|
|
@ -4,4 +4,6 @@
|
|||
<!-- Created to detect what has to be implemented (especially in the settings) -->
|
||||
<bool name="false_not_implemented">false</bool>
|
||||
|
||||
<bool name="is_rtl">false</bool>
|
||||
|
||||
</resources>
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -17,24 +17,25 @@
|
|||
package im.vector.app.core.epoxy.bottomsheet
|
||||
|
||||
import android.text.method.MovementMethod
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import com.airbnb.epoxy.EpoxyAttribute
|
||||
import com.airbnb.epoxy.EpoxyModelClass
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import im.vector.app.R
|
||||
import im.vector.app.core.epoxy.ClickListener
|
||||
import im.vector.app.core.epoxy.VectorEpoxyHolder
|
||||
import im.vector.app.core.epoxy.VectorEpoxyModel
|
||||
import im.vector.app.core.epoxy.onClick
|
||||
import im.vector.app.core.extensions.setTextOrHide
|
||||
import im.vector.app.core.glide.GlideApp
|
||||
import im.vector.app.features.displayname.getBestName
|
||||
import im.vector.app.features.home.AvatarRenderer
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
|
||||
import im.vector.app.features.home.room.detail.timeline.item.BindingOptions
|
||||
import im.vector.app.features.home.room.detail.timeline.tools.findPillsAndProcess
|
||||
import im.vector.app.features.location.LocationData
|
||||
import im.vector.app.features.location.MapTilerMapView
|
||||
import im.vector.app.features.media.ImageContentRenderer
|
||||
import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence
|
||||
import org.matrix.android.sdk.api.util.MatrixItem
|
||||
|
@ -70,7 +71,7 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
|
|||
var time: String? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
var locationData: LocationData? = null
|
||||
var locationUrl: String? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
var locationPinProvider: LocationPinProvider? = null
|
||||
|
@ -97,17 +98,21 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
|
|||
body.charSequence.findPillsAndProcess(coroutineScope) { it.bind(holder.body) }
|
||||
holder.timestamp.setTextOrHide(time)
|
||||
|
||||
holder.mapView.isVisible = locationData != null
|
||||
holder.body.isVisible = locationData == null
|
||||
locationData?.let { location ->
|
||||
holder.mapView.initialize {
|
||||
if (holder.view.isAttachedToWindow) {
|
||||
holder.mapView.zoomToLocation(location.latitude, location.longitude, 15.0)
|
||||
locationPinProvider?.create(matrixItem.id) { pinDrawable ->
|
||||
holder.mapView.addPinToMap(matrixItem.id, pinDrawable)
|
||||
holder.mapView.updatePinLocation(matrixItem.id, location.latitude, location.longitude)
|
||||
}
|
||||
}
|
||||
if (locationUrl == null) {
|
||||
holder.body.isVisible = true
|
||||
holder.mapViewContainer.isVisible = false
|
||||
} else {
|
||||
holder.body.isVisible = false
|
||||
holder.mapViewContainer.isVisible = true
|
||||
GlideApp.with(holder.staticMapImageView)
|
||||
.load(locationUrl)
|
||||
.apply(RequestOptions.centerCropTransform())
|
||||
.into(holder.staticMapImageView)
|
||||
|
||||
locationPinProvider?.create(matrixItem.id) { pinDrawable ->
|
||||
GlideApp.with(holder.staticMapPinImageView)
|
||||
.load(pinDrawable)
|
||||
.into(holder.staticMapPinImageView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -124,6 +129,8 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
|
|||
val bodyDetails by bind<TextView>(R.id.bottom_sheet_message_preview_body_details)
|
||||
val timestamp by bind<TextView>(R.id.bottom_sheet_message_preview_timestamp)
|
||||
val imagePreview by bind<ImageView>(R.id.bottom_sheet_message_preview_image)
|
||||
val mapView by bind<MapTilerMapView>(R.id.bottom_sheet_message_preview_location)
|
||||
val mapViewContainer by bind<FrameLayout>(R.id.mapViewContainer)
|
||||
val staticMapImageView by bind<ImageView>(R.id.staticMapImageView)
|
||||
val staticMapPinImageView by bind<ImageView>(R.id.staticMapPinImageView)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -39,7 +39,9 @@ import im.vector.app.features.home.room.detail.timeline.item.E2EDecoration
|
|||
import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod
|
||||
import im.vector.app.features.home.room.detail.timeline.tools.linkify
|
||||
import im.vector.app.features.html.SpanUtils
|
||||
import im.vector.app.features.location.LocationData
|
||||
import im.vector.app.features.location.INITIAL_MAP_ZOOM_IN_TIMELINE
|
||||
import im.vector.app.features.location.UrlMapProvider
|
||||
import im.vector.app.features.location.toLocationData
|
||||
import im.vector.app.features.media.ImageContentRenderer
|
||||
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
|
||||
import org.matrix.android.sdk.api.extensions.orFalse
|
||||
|
@ -62,6 +64,7 @@ class MessageActionsEpoxyController @Inject constructor(
|
|||
private val spanUtils: SpanUtils,
|
||||
private val eventDetailsFormatter: EventDetailsFormatter,
|
||||
private val dateFormatter: VectorDateFormatter,
|
||||
private val urlMapProvider: UrlMapProvider,
|
||||
private val locationPinProvider: LocationPinProvider
|
||||
) : TypedEpoxyController<MessageActionState>() {
|
||||
|
||||
|
@ -74,9 +77,11 @@ class MessageActionsEpoxyController @Inject constructor(
|
|||
val formattedDate = dateFormatter.format(date, DateFormatKind.MESSAGE_DETAIL)
|
||||
val body = state.messageBody.linkify(host.listener)
|
||||
val bindingOptions = spanUtils.getBindingOptions(body)
|
||||
val locationData = state.timelineEvent()?.root?.getClearContent()?.toModel<MessageLocationContent>(catchError = true)?.let {
|
||||
LocationData.create(it.getUri())
|
||||
}
|
||||
val locationUrl = state.timelineEvent()?.root?.getClearContent()
|
||||
?.toModel<MessageLocationContent>(catchError = true)
|
||||
?.toLocationData()
|
||||
?.let { urlMapProvider.buildStaticMapUrl(it, INITIAL_MAP_ZOOM_IN_TIMELINE, 1200, 800) }
|
||||
|
||||
bottomSheetMessagePreviewItem {
|
||||
id("preview")
|
||||
avatarRenderer(host.avatarRenderer)
|
||||
|
@ -89,7 +94,7 @@ class MessageActionsEpoxyController @Inject constructor(
|
|||
body(body.toEpoxyCharSequence())
|
||||
bodyDetails(host.eventDetailsFormatter.format(state.timelineEvent()?.root)?.toEpoxyCharSequence())
|
||||
time(formattedDate)
|
||||
locationData(locationData)
|
||||
locationUrl(locationUrl)
|
||||
locationPinProvider(host.locationPinProvider)
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -28,6 +28,7 @@ import im.vector.app.core.glide.GlideApp
|
|||
import im.vector.app.core.utils.DimensionConverter
|
||||
import im.vector.app.features.home.AvatarRenderer
|
||||
import org.matrix.android.sdk.api.util.toMatrixItem
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
@ -54,22 +55,36 @@ class LocationPinProvider @Inject constructor(
|
|||
val size = dimensionConverter.dpToPx(44)
|
||||
avatarRenderer.render(glideRequests, it, object : CustomTarget<Drawable>(size, size) {
|
||||
override fun onResourceReady(resource: Drawable, transition: Transition<in Drawable>?) {
|
||||
val bgUserPin = ContextCompat.getDrawable(context, R.drawable.bg_map_user_pin)!!
|
||||
val layerDrawable = LayerDrawable(arrayOf(bgUserPin, resource))
|
||||
val horizontalInset = dimensionConverter.dpToPx(4)
|
||||
val topInset = dimensionConverter.dpToPx(4)
|
||||
val bottomInset = dimensionConverter.dpToPx(8)
|
||||
layerDrawable.setLayerInset(1, horizontalInset, topInset, horizontalInset, bottomInset)
|
||||
|
||||
cache[userId] = layerDrawable
|
||||
|
||||
callback(layerDrawable)
|
||||
Timber.d("## Location: onResourceReady")
|
||||
val pinDrawable = createPinDrawable(resource)
|
||||
cache[userId] = pinDrawable
|
||||
callback(pinDrawable)
|
||||
}
|
||||
|
||||
override fun onLoadCleared(placeholder: Drawable?) {
|
||||
// Is it possible? Put placeholder instead?
|
||||
// FIXME The doc says it has to be implemented and should free resources
|
||||
Timber.d("## Location: onLoadCleared")
|
||||
}
|
||||
|
||||
override fun onLoadFailed(errorDrawable: Drawable?) {
|
||||
Timber.w("## Location: onLoadFailed")
|
||||
errorDrawable ?: return
|
||||
val pinDrawable = createPinDrawable(errorDrawable)
|
||||
cache[userId] = pinDrawable
|
||||
callback(pinDrawable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPinDrawable(drawable: Drawable): Drawable {
|
||||
val bgUserPin = ContextCompat.getDrawable(context, R.drawable.bg_map_user_pin)!!
|
||||
val layerDrawable = LayerDrawable(arrayOf(bgUserPin, drawable))
|
||||
val horizontalInset = dimensionConverter.dpToPx(4)
|
||||
val topInset = dimensionConverter.dpToPx(4)
|
||||
val bottomInset = dimensionConverter.dpToPx(8)
|
||||
layerDrawable.setLayerInset(1, horizontalInset, topInset, horizontalInset, bottomInset)
|
||||
return layerDrawable
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,28 +16,19 @@
|
|||
|
||||
package im.vector.app.features.home.room.detail.timeline.item
|
||||
|
||||
import android.widget.FrameLayout
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import android.widget.ImageView
|
||||
import com.airbnb.epoxy.EpoxyAttribute
|
||||
import com.airbnb.epoxy.EpoxyModelClass
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import im.vector.app.R
|
||||
import im.vector.app.core.epoxy.onClick
|
||||
import im.vector.app.core.glide.GlideApp
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
|
||||
import im.vector.app.features.location.LocationData
|
||||
import im.vector.app.features.location.MapTilerMapView
|
||||
|
||||
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
|
||||
abstract class MessageLocationItem : AbsMessageItem<MessageLocationItem.Holder>() {
|
||||
|
||||
interface Callback {
|
||||
fun onMapClicked()
|
||||
}
|
||||
|
||||
@EpoxyAttribute
|
||||
var callback: Callback? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
var locationData: LocationData? = null
|
||||
var locationUrl: String? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
var userId: String? = null
|
||||
|
@ -47,37 +38,31 @@ abstract class MessageLocationItem : AbsMessageItem<MessageLocationItem.Holder>(
|
|||
|
||||
override fun bind(holder: Holder) {
|
||||
super.bind(holder)
|
||||
renderSendState(holder.mapViewContainer, null)
|
||||
renderSendState(holder.view, null)
|
||||
|
||||
val location = locationData ?: return
|
||||
val location = locationUrl ?: return
|
||||
val locationOwnerId = userId ?: return
|
||||
|
||||
holder.clickableMapArea.onClick {
|
||||
callback?.onMapClicked()
|
||||
}
|
||||
GlideApp.with(holder.staticMapImageView)
|
||||
.load(location)
|
||||
.apply(RequestOptions.centerCropTransform())
|
||||
.into(holder.staticMapImageView)
|
||||
|
||||
holder.mapView.apply {
|
||||
initialize {
|
||||
zoomToLocation(location.latitude, location.longitude, INITIAL_ZOOM)
|
||||
|
||||
locationPinProvider?.create(locationOwnerId) { pinDrawable ->
|
||||
addPinToMap(locationOwnerId, pinDrawable)
|
||||
updatePinLocation(locationOwnerId, location.latitude, location.longitude)
|
||||
}
|
||||
}
|
||||
locationPinProvider?.create(locationOwnerId) { pinDrawable ->
|
||||
GlideApp.with(holder.staticMapPinImageView)
|
||||
.load(pinDrawable)
|
||||
.into(holder.staticMapPinImageView)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getViewType() = STUB_ID
|
||||
|
||||
class Holder : AbsMessageItem.Holder(STUB_ID) {
|
||||
val mapViewContainer by bind<ConstraintLayout>(R.id.mapViewContainer)
|
||||
val mapView by bind<MapTilerMapView>(R.id.mapView)
|
||||
val clickableMapArea by bind<FrameLayout>(R.id.clickableMapArea)
|
||||
val staticMapImageView by bind<ImageView>(R.id.staticMapImageView)
|
||||
val staticMapPinImageView by bind<ImageView>(R.id.staticMapPinImageView)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STUB_ID = R.id.messageContentLocationStub
|
||||
private const val INITIAL_ZOOM = 15.0
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -21,20 +21,30 @@ import android.view.LayoutInflater
|
|||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.airbnb.mvrx.args
|
||||
import com.mapbox.mapboxsdk.maps.MapView
|
||||
import im.vector.app.R
|
||||
import im.vector.app.core.platform.VectorBaseFragment
|
||||
import im.vector.app.core.utils.openLocation
|
||||
import im.vector.app.databinding.FragmentLocationPreviewBinding
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
|
||||
import java.lang.ref.WeakReference
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* TODO Move locationPinProvider to a ViewModel
|
||||
*/
|
||||
class LocationPreviewFragment @Inject constructor(
|
||||
private val urlMapProvider: UrlMapProvider,
|
||||
private val locationPinProvider: LocationPinProvider
|
||||
) : VectorBaseFragment<FragmentLocationPreviewBinding>() {
|
||||
|
||||
private val args: LocationSharingArgs by args()
|
||||
|
||||
// Keep a ref to handle properly the onDestroy callback
|
||||
private var mapView: WeakReference<MapView>? = null
|
||||
|
||||
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationPreviewBinding {
|
||||
return FragmentLocationPreviewBinding.inflate(layoutInflater, container, false)
|
||||
}
|
||||
|
@ -42,11 +52,15 @@ class LocationPreviewFragment @Inject constructor(
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
views.mapView.initialize {
|
||||
if (isAdded) {
|
||||
onMapReady()
|
||||
}
|
||||
}
|
||||
mapView = WeakReference(views.mapView)
|
||||
views.mapView.onCreate(savedInstanceState)
|
||||
views.mapView.initialize(urlMapProvider.mapUrl)
|
||||
loadPinDrawable()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
views.mapView.onResume()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
@ -54,11 +68,32 @@ class LocationPreviewFragment @Inject constructor(
|
|||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onLowMemory() {
|
||||
views.mapView.onLowMemory()
|
||||
super.onLowMemory()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
views.mapView.onStart()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
views.mapView.onStop()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
views.mapView.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
mapView?.get()?.onDestroy()
|
||||
mapView?.clear()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun getMenuRes() = R.menu.menu_location_preview
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
|
@ -76,18 +111,20 @@ class LocationPreviewFragment @Inject constructor(
|
|||
openLocation(requireActivity(), location.latitude, location.longitude)
|
||||
}
|
||||
|
||||
private fun onMapReady() {
|
||||
if (!isAdded) return
|
||||
|
||||
private fun loadPinDrawable() {
|
||||
val location = args.initialLocationData ?: return
|
||||
val userId = args.locationOwnerId
|
||||
|
||||
locationPinProvider.create(userId) { pinDrawable ->
|
||||
views.mapView.apply {
|
||||
zoomToLocation(location.latitude, location.longitude, INITIAL_MAP_ZOOM)
|
||||
deleteAllPins()
|
||||
addPinToMap(userId, pinDrawable)
|
||||
updatePinLocation(userId, location.latitude, location.longitude)
|
||||
lifecycleScope.launchWhenResumed {
|
||||
views.mapView.render(
|
||||
MapState(
|
||||
zoomOnlyOnce = true,
|
||||
pinLocationData = location,
|
||||
pinId = args.locationOwnerId,
|
||||
pinDrawable = pinDrawable
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -20,29 +20,29 @@ import android.os.Bundle
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isGone
|
||||
import com.airbnb.mvrx.fragmentViewModel
|
||||
import com.airbnb.mvrx.withState
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.mapbox.mapboxsdk.maps.MapView
|
||||
import im.vector.app.R
|
||||
import im.vector.app.core.extensions.exhaustive
|
||||
import im.vector.app.core.platform.VectorBaseFragment
|
||||
import im.vector.app.databinding.FragmentLocationSharingBinding
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
|
||||
import org.matrix.android.sdk.api.session.Session
|
||||
import java.lang.ref.WeakReference
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* We should consider using SupportMapFragment for a out of the box lifecycle handling
|
||||
*/
|
||||
class LocationSharingFragment @Inject constructor(
|
||||
private val locationTracker: LocationTracker,
|
||||
private val session: Session,
|
||||
private val locationPinProvider: LocationPinProvider
|
||||
) : VectorBaseFragment<FragmentLocationSharingBinding>(), LocationTracker.Callback {
|
||||
|
||||
init {
|
||||
locationTracker.callback = this
|
||||
}
|
||||
private val urlMapProvider: UrlMapProvider
|
||||
) : VectorBaseFragment<FragmentLocationSharingBinding>() {
|
||||
|
||||
private val viewModel: LocationSharingViewModel by fragmentViewModel()
|
||||
|
||||
private var lastZoomValue: Double = -1.0
|
||||
// Keep a ref to handle properly the onDestroy callback
|
||||
private var mapView: WeakReference<MapView>? = null
|
||||
|
||||
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationSharingBinding {
|
||||
return FragmentLocationSharingBinding.inflate(inflater, container, false)
|
||||
|
@ -51,11 +51,9 @@ class LocationSharingFragment @Inject constructor(
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
views.mapView.initialize {
|
||||
if (isAdded) {
|
||||
onMapReady()
|
||||
}
|
||||
}
|
||||
mapView = WeakReference(views.mapView)
|
||||
views.mapView.onCreate(savedInstanceState)
|
||||
views.mapView.initialize(urlMapProvider.mapUrl)
|
||||
|
||||
views.shareLocationContainer.debouncedClicks {
|
||||
viewModel.handle(LocationSharingAction.OnShareLocation)
|
||||
|
@ -63,54 +61,48 @@ class LocationSharingFragment @Inject constructor(
|
|||
|
||||
viewModel.observeViewEvents {
|
||||
when (it) {
|
||||
LocationSharingViewEvents.LocationNotAvailableError -> handleLocationNotAvailableError()
|
||||
LocationSharingViewEvents.Close -> activity?.finish()
|
||||
LocationSharingViewEvents.LocationNotAvailableError -> handleLocationNotAvailableError()
|
||||
LocationSharingViewEvents.Close -> activity?.finish()
|
||||
}.exhaustive
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
views.mapView.onResume()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
views.mapView.onPause()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
views.mapView.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
views.mapView.onStart()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
views.mapView.onStop()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onLowMemory() {
|
||||
super.onLowMemory()
|
||||
views.mapView.onLowMemory()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
locationTracker.stop()
|
||||
mapView?.get()?.onDestroy()
|
||||
mapView?.clear()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun onMapReady() {
|
||||
if (!isAdded) return
|
||||
|
||||
locationPinProvider.create(session.myUserId) {
|
||||
views.mapView.addPinToMap(
|
||||
pinId = USER_PIN_NAME,
|
||||
image = it,
|
||||
)
|
||||
// All set, start location tracker
|
||||
locationTracker.start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLocationUpdate(locationData: LocationData) {
|
||||
lastZoomValue = if (lastZoomValue == -1.0) INITIAL_MAP_ZOOM else views.mapView.getCurrentZoom() ?: INITIAL_MAP_ZOOM
|
||||
|
||||
views.mapView.zoomToLocation(locationData.latitude, locationData.longitude, lastZoomValue)
|
||||
views.mapView.deleteAllPins()
|
||||
views.mapView.updatePinLocation(USER_PIN_NAME, locationData.latitude, locationData.longitude)
|
||||
|
||||
viewModel.handle(LocationSharingAction.OnLocationUpdate(locationData))
|
||||
}
|
||||
|
||||
override fun onLocationProviderIsNotAvailable() {
|
||||
viewModel.handle(LocationSharingAction.OnLocationProviderIsNotAvailable)
|
||||
}
|
||||
|
||||
private fun handleLocationNotAvailableError() {
|
||||
MaterialAlertDialogBuilder(requireActivity())
|
||||
.setTitle(R.string.location_not_available_dialog_title)
|
||||
|
@ -118,9 +110,15 @@ class LocationSharingFragment @Inject constructor(
|
|||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
activity?.finish()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun invalidate() = withState(viewModel) { state ->
|
||||
views.mapView.render(state.toMapState())
|
||||
views.shareLocationGpsLoading.isGone = state.lastKnownLocation != null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val USER_PIN_NAME = "USER_PIN_NAME"
|
||||
}
|
||||
|
|
|
@ -24,12 +24,15 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
|
|||
import im.vector.app.core.di.hiltMavericksViewModelFactory
|
||||
import im.vector.app.core.extensions.exhaustive
|
||||
import im.vector.app.core.platform.VectorViewModel
|
||||
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
|
||||
import org.matrix.android.sdk.api.session.Session
|
||||
|
||||
class LocationSharingViewModel @AssistedInject constructor(
|
||||
@Assisted private val initialState: LocationSharingViewState,
|
||||
session: Session
|
||||
) : VectorViewModel<LocationSharingViewState, LocationSharingAction, LocationSharingViewEvents>(initialState) {
|
||||
private val locationTracker: LocationTracker,
|
||||
private val locationPinProvider: LocationPinProvider,
|
||||
private val session: Session
|
||||
) : VectorViewModel<LocationSharingViewState, LocationSharingAction, LocationSharingViewEvents>(initialState), LocationTracker.Callback {
|
||||
|
||||
private val room = session.getRoom(initialState.roomId)!!
|
||||
|
||||
|
@ -38,14 +41,31 @@ class LocationSharingViewModel @AssistedInject constructor(
|
|||
override fun create(initialState: LocationSharingViewState): LocationSharingViewModel
|
||||
}
|
||||
|
||||
companion object : MavericksViewModelFactory<LocationSharingViewModel, LocationSharingViewState> by hiltMavericksViewModelFactory() {
|
||||
companion object : MavericksViewModelFactory<LocationSharingViewModel, LocationSharingViewState> by hiltMavericksViewModelFactory()
|
||||
|
||||
init {
|
||||
locationTracker.start(this)
|
||||
createPin()
|
||||
}
|
||||
|
||||
private fun createPin() {
|
||||
locationPinProvider.create(session.myUserId) {
|
||||
setState {
|
||||
copy(
|
||||
pinDrawable = it
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
locationTracker.stop()
|
||||
}
|
||||
|
||||
override fun handle(action: LocationSharingAction) {
|
||||
when (action) {
|
||||
is LocationSharingAction.OnLocationUpdate -> handleLocationUpdate(action.locationData)
|
||||
LocationSharingAction.OnShareLocation -> handleShareLocation()
|
||||
LocationSharingAction.OnLocationProviderIsNotAvailable -> handleLocationProviderIsNotAvailable()
|
||||
LocationSharingAction.OnShareLocation -> handleShareLocation()
|
||||
}.exhaustive
|
||||
}
|
||||
|
||||
|
@ -62,13 +82,13 @@ class LocationSharingViewModel @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleLocationUpdate(locationData: LocationData) {
|
||||
override fun onLocationUpdate(locationData: LocationData) {
|
||||
setState {
|
||||
copy(lastKnownLocation = locationData)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleLocationProviderIsNotAvailable() {
|
||||
override fun onLocationProviderIsNotAvailable() {
|
||||
_viewEvents.post(LocationSharingViewEvents.LocationNotAvailableError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -19,70 +19,108 @@ package im.vector.app.features.location
|
|||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.location.LocationListenerCompat
|
||||
import im.vector.app.BuildConfig
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
class LocationTracker @Inject constructor(
|
||||
private val context: Context
|
||||
) : LocationListener {
|
||||
context: Context
|
||||
) : LocationListenerCompat {
|
||||
|
||||
private val locationManager = context.getSystemService<LocationManager>()
|
||||
|
||||
interface Callback {
|
||||
fun onLocationUpdate(locationData: LocationData)
|
||||
fun onLocationProviderIsNotAvailable()
|
||||
}
|
||||
|
||||
private var locationManager: LocationManager? = null
|
||||
var callback: Callback? = null
|
||||
private var callback: Callback? = null
|
||||
|
||||
private var hasGpsProviderLiveLocation = false
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
|
||||
fun start() {
|
||||
val locationManager = context.getSystemService<LocationManager>()
|
||||
fun start(callback: Callback?) {
|
||||
Timber.d("## LocationTracker. start()")
|
||||
hasGpsProviderLiveLocation = false
|
||||
this.callback = callback
|
||||
|
||||
locationManager?.let {
|
||||
val isGpsEnabled = it.isProviderEnabled(LocationManager.GPS_PROVIDER)
|
||||
val isNetworkEnabled = it.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||
|
||||
val provider = when {
|
||||
isGpsEnabled -> LocationManager.GPS_PROVIDER
|
||||
isNetworkEnabled -> LocationManager.NETWORK_PROVIDER
|
||||
else -> {
|
||||
callback?.onLocationProviderIsNotAvailable()
|
||||
Timber.v("## LocationTracker. There is no location provider available")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Send last known location without waiting location updates
|
||||
it.getLastKnownLocation(provider)?.let { lastKnownLocation ->
|
||||
callback?.onLocationUpdate(lastKnownLocation.toLocationData())
|
||||
}
|
||||
|
||||
it.requestLocationUpdates(
|
||||
provider,
|
||||
MIN_TIME_MILLIS_TO_UPDATE_LOCATION,
|
||||
MIN_DISTANCE_METERS_TO_UPDATE_LOCATION,
|
||||
this
|
||||
)
|
||||
} ?: run {
|
||||
if (locationManager == null) {
|
||||
callback?.onLocationProviderIsNotAvailable()
|
||||
Timber.v("## LocationTracker. LocationManager is not available")
|
||||
return
|
||||
}
|
||||
|
||||
locationManager.allProviders
|
||||
.takeIf { it.isNotEmpty() }
|
||||
// Take GPS first
|
||||
?.sortedByDescending { if (it == LocationManager.GPS_PROVIDER) 1 else 0 }
|
||||
?.forEach { provider ->
|
||||
Timber.d("## LocationTracker. track location using $provider")
|
||||
|
||||
// Send last known location without waiting location updates
|
||||
locationManager.getLastKnownLocation(provider)?.let { lastKnownLocation ->
|
||||
if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) {
|
||||
Timber.d("## LocationTracker. lastKnownLocation: $lastKnownLocation")
|
||||
} else {
|
||||
Timber.d("## LocationTracker. lastKnownLocation: ${lastKnownLocation.provider}")
|
||||
}
|
||||
notifyLocation(lastKnownLocation, isLive = false)
|
||||
}
|
||||
|
||||
locationManager.requestLocationUpdates(
|
||||
provider,
|
||||
MIN_TIME_TO_UPDATE_LOCATION_MILLIS,
|
||||
MIN_DISTANCE_TO_UPDATE_LOCATION_METERS,
|
||||
this
|
||||
)
|
||||
}
|
||||
?: run {
|
||||
callback?.onLocationProviderIsNotAvailable()
|
||||
Timber.v("## LocationTracker. There is no location provider available")
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
|
||||
fun stop() {
|
||||
Timber.d("## LocationTracker. stop()")
|
||||
locationManager?.removeUpdates(this)
|
||||
callback = null
|
||||
}
|
||||
|
||||
override fun onLocationChanged(location: Location) {
|
||||
if (BuildConfig.LOW_PRIVACY_LOG_ENABLE) {
|
||||
Timber.d("## LocationTracker. onLocationChanged: $location")
|
||||
} else {
|
||||
Timber.d("## LocationTracker. onLocationChanged: ${location.provider}")
|
||||
}
|
||||
notifyLocation(location, isLive = true)
|
||||
}
|
||||
|
||||
private fun notifyLocation(location: Location, isLive: Boolean) {
|
||||
when (location.provider) {
|
||||
LocationManager.GPS_PROVIDER -> {
|
||||
hasGpsProviderLiveLocation = isLive
|
||||
}
|
||||
else -> {
|
||||
if (hasGpsProviderLiveLocation) {
|
||||
// Ignore this update
|
||||
Timber.d("## LocationTracker. ignoring location from ${location.provider}, we have gps live location")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
callback?.onLocationUpdate(location.toLocationData())
|
||||
}
|
||||
|
||||
override fun onProviderDisabled(provider: String) {
|
||||
Timber.d("## LocationTracker. onProviderDisabled: $provider")
|
||||
callback?.onLocationProviderIsNotAvailable()
|
||||
}
|
||||
|
||||
private fun Location.toLocationData(): LocationData {
|
||||
return LocationData(latitude, longitude, accuracy.toDouble())
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -48,4 +48,13 @@
|
|||
android:textColor="?colorPrimary"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/shareLocationGpsLoading"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="@dimen/layout_horizontal_margin"
|
||||
app:layout_constraintBottom_toBottomOf="@id/shareLocationContainer"
|
||||
app:layout_constraintEnd_toEndOf="@id/shareLocationContainer"
|
||||
app:layout_constraintTop_toTopOf="@id/shareLocationContainer" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -103,18 +103,34 @@
|
|||
tools:text="1080 x 1024 - 43s - 12kB"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<im.vector.app.features.location.MapTilerMapView
|
||||
android:id="@+id/bottom_sheet_message_preview_location"
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/mapViewContainer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="200dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:contentDescription="@string/attachment_type_location"
|
||||
android:scaleType="centerCrop"
|
||||
android:visibility="gone"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:layout_constraintEnd_toEndOf="@id/bottom_sheet_message_preview_timestamp"
|
||||
app:layout_constraintStart_toStartOf="@id/bottom_sheet_message_preview_sender"
|
||||
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_sender"
|
||||
app:mapbox_renderTextureMode="true"
|
||||
tools:visibility="visible" />
|
||||
tools:alpha="0.3"
|
||||
tools:visibility="visible">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/staticMapImageView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="200dp"
|
||||
android:contentDescription="@string/a11y_static_map_image" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/staticMapPinImageView"
|
||||
android:layout_width="51dp"
|
||||
android:layout_height="55dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginBottom="28dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/bg_map_user_pin" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
|
@ -6,30 +6,19 @@
|
|||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="8dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/mapViewContainer"
|
||||
<ImageView
|
||||
android:id="@+id/staticMapImageView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
android:layout_height="200dp"
|
||||
android:contentDescription="@string/a11y_static_map_image" />
|
||||
|
||||
<im.vector.app.features.location.MapTilerMapView
|
||||
android:id="@+id/mapView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="200dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:mapbox_renderTextureMode="true" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/clickableMapArea"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="@id/mapView"
|
||||
app:layout_constraintEnd_toEndOf="@id/mapView"
|
||||
app:layout_constraintStart_toStartOf="@id/mapView"
|
||||
app:layout_constraintTop_toTopOf="@id/mapView" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
<ImageView
|
||||
android:id="@+id/staticMapPinImageView"
|
||||
android:layout_width="51dp"
|
||||
android:layout_height="55dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginBottom="28dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/bg_map_user_pin" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
|
|
@ -3711,6 +3711,7 @@
|
|||
<string name="location_activity_title_static_sharing">Share location</string>
|
||||
<string name="location_activity_title_preview">Location</string>
|
||||
<string name="a11y_location_share_icon">Share location</string>
|
||||
<string name="a11y_static_map_image">Map</string>
|
||||
<string name="location_share">Share location</string>
|
||||
<string name="template_location_not_available_dialog_title">${app_name} could not access your location</string>
|
||||
<string name="template_location_not_available_dialog_content">${app_name} could not access your location. Please try again later.</string>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue