Merge pull request #4817 from vector-im/feature/ons/static_location

Static Location Sharing
This commit is contained in:
Benoit Marty 2022-01-25 18:03:17 +01:00 committed by GitHub
commit f14bf0dd50
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 1548 additions and 56 deletions

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

@ -0,0 +1 @@
Static location sharing and rendering

View file

@ -83,6 +83,7 @@ ext.groups = [
'com.jakewharton.android.repackaged',
'com.jakewharton.timber',
'com.linkedin.dexmaker',
'com.mapbox.mapboxsdk',
'com.nulab-inc',
'com.otaliastudios.opengl',
'com.parse.bolts',
@ -159,6 +160,7 @@ ext.groups = [
'org.junit.jupiter',
'org.junit.platform',
'org.jvnet.staxex',
'org.maplibre.gl',
'org.matrix.android',
'org.mockito',
'org.mongodb',

View file

@ -0,0 +1,25 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.api.session.room.model.message
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LocationAsset(
@Json(name = "type") val type: LocationAssetType? = null
)

View file

@ -0,0 +1,26 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.api.session.room.model.message
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = false)
enum class LocationAssetType {
@Json(name = "m.self")
SELF
}

View file

@ -18,29 +18,17 @@ package org.matrix.android.sdk.api.session.room.model.message
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.internal.crypto.model.rest.EncryptedFileInfo
@JsonClass(generateAdapter = true)
data class LocationInfo(
/**
* The URL to the thumbnail of the file. Only present if the thumbnail is unencrypted.
* Required. RFC5870 formatted geo uri 'geo:latitude,longitude;uncertainty' like 'geo:40.05,29.24;30' representing this location.
*/
@Json(name = "thumbnail_url") val thumbnailUrl: String? = null,
@Json(name = "uri") val geoUri: String? = null,
/**
* Metadata about the image referred to in thumbnail_url.
* Required. A description of the location e.g. 'Big Ben, London, UK', or some kind
* of content description for accessibility e.g. 'location attachment'.
*/
@Json(name = "thumbnail_info") val thumbnailInfo: ThumbnailInfo? = null,
/**
* Information on the encrypted thumbnail file, as specified in End-to-end encryption. Only present if the thumbnail is encrypted.
*/
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null
@Json(name = "description") val description: String? = null
)
/**
* Get the url of the encrypted thumbnail or of the thumbnail
*/
fun LocationInfo.getThumbnailUrl(): String? {
return thumbnailFile?.url ?: thumbnailUrl
}

View file

@ -26,7 +26,7 @@ data class MessageLocationContent(
/**
* Required. Must be 'm.location'.
*/
@Json(name = MessageContent.MSG_TYPE_JSON_KEY) override val msgType: String,
@Json(name = MessageContent.MSG_TYPE_JSON_KEY) override val msgType: String = MessageType.MSGTYPE_LOCATION,
/**
* Required. A description of the location e.g. 'Big Ben, London, UK', or some kind
@ -35,15 +35,32 @@ data class MessageLocationContent(
@Json(name = "body") override val body: String,
/**
* Required. A geo URI representing this location.
* Required. RFC5870 formatted geo uri 'geo:latitude,longitude;uncertainty' like 'geo:40.05,29.24;30' representing this location.
*/
@Json(name = "geo_uri") val geoUri: String,
/**
*
* See https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md
*/
@Json(name = "info") val locationInfo: LocationInfo? = null,
@Json(name = "org.matrix.msc3488.location") val locationInfo: LocationInfo? = null,
@Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null,
@Json(name = "m.new_content") override val newContent: Content? = null
) : MessageContent
@Json(name = "m.new_content") override val newContent: Content? = null,
/**
* m.asset defines a generic asset that can be used for location tracking but also in other places like inventories, geofencing, checkins/checkouts etc.
* It should contain a mandatory namespaced type key defining what particular asset is being referred to.
* For the purposes of user location tracking m.self should be used in order to avoid duplicating the mxid.
*/
@Json(name = "m.asset") val locationAsset: LocationAsset? = null,
/**
* Exact time that the data in the event refers to (milliseconds since the UNIX epoch)
*/
@Json(name = "org.matrix.msc3488.ts") val ts: Long? = null,
@Json(name = "org.matrix.msc1767.text") val text: String? = null
) : MessageContent {
fun getUri() = locationInfo?.geoUri ?: geoUri
}

View file

@ -133,6 +133,14 @@ interface SendService {
*/
fun resendMediaMessage(localEcho: TimelineEvent): Cancelable
/**
* Send a location event to the room
* @param latitude required latitude of the location
* @param longitude required longitude of the location
* @param uncertainty Accuracy of the location in meters
*/
fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?): Cancelable
/**
* Remove this failed message from the timeline
* @param localEcho the unsent local echo

View file

@ -122,6 +122,12 @@ internal class DefaultSendService @AssistedInject constructor(
.let { sendEvent(it) }
}
override fun sendLocation(latitude: Double, longitude: Double, uncertainty: Double?): Cancelable {
return localEchoEventFactory.createLocationEvent(roomId, latitude, longitude, uncertainty)
.also { createLocalEcho(it) }
.let { sendEvent(it) }
}
override fun redactEvent(event: Event, reason: String?): Cancelable {
// TODO manage media/attachements?
val redactionEcho = localEchoEventFactory.createRedactEvent(roomId, event.eventId!!, reason)

View file

@ -32,6 +32,9 @@ import org.matrix.android.sdk.api.session.room.model.message.AudioInfo
import org.matrix.android.sdk.api.session.room.model.message.AudioWaveformInfo
import org.matrix.android.sdk.api.session.room.model.message.FileInfo
import org.matrix.android.sdk.api.session.room.model.message.ImageInfo
import org.matrix.android.sdk.api.session.room.model.message.LocationAsset
import org.matrix.android.sdk.api.session.room.model.message.LocationAssetType
import org.matrix.android.sdk.api.session.room.model.message.LocationInfo
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody
@ -39,6 +42,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageEndPollConte
import org.matrix.android.sdk.api.session.room.model.message.MessageFileContent
import org.matrix.android.sdk.api.session.room.model.message.MessageFormat
import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
@ -63,6 +67,7 @@ import org.matrix.android.sdk.internal.session.content.ThumbnailExtractor
import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory
import org.matrix.android.sdk.internal.session.room.send.pills.TextPillsUtils
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
/**
@ -224,6 +229,27 @@ internal class LocalEchoEventFactory @Inject constructor(
unsignedData = UnsignedData(age = null, transactionId = localId))
}
fun createLocationEvent(roomId: String,
latitude: Double,
longitude: Double,
uncertainty: Double?): Event {
val geoUri = buildGeoUri(latitude, longitude, uncertainty)
val content = MessageLocationContent(
geoUri = geoUri,
body = geoUri,
locationInfo = LocationInfo(
geoUri = geoUri,
description = geoUri
),
locationAsset = LocationAsset(
type = LocationAssetType.SELF
),
ts = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()),
text = geoUri
)
return createMessageEvent(roomId, content)
}
fun createReplaceTextOfReply(roomId: String,
eventReplaced: TimelineEvent,
originalEvent: TimelineEvent,
@ -510,6 +536,23 @@ internal class LocalEchoEventFactory @Inject constructor(
}
}
/**
* Returns RFC5870 formatted geo uri 'geo:latitude,longitude;uncertainty' like 'geo:40.05,29.24;30'
* Uncertainty of the location is in meters and not required.
*/
private fun buildGeoUri(latitude: Double, longitude: Double, uncertainty: Double?): String {
return buildString {
append("geo:")
append(latitude)
append(",")
append(longitude)
uncertainty?.let {
append(";")
append(it)
}
}
}
/*
* {
"content": {

View file

@ -153,6 +153,9 @@ android {
// This *must* only be set in trusted environments.
buildConfigField "Boolean", "handleCallAssertedIdentityEvents", "false"
buildConfigField "Boolean", "enableLocationSharing", "true"
buildConfigField "String", "mapTilerKey", "\"fU3vlMsMn4Jb6dnEIFsx\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// Keep abiFilter for the universalApk
@ -498,6 +501,10 @@ dependencies {
}
implementation 'commons-codec:commons-codec:1.15'
// MapTiler
implementation 'org.maplibre.gl:android-sdk:9.5.2'
implementation 'org.maplibre.gl:android-plugin-annotation-v9:1.0.0'
// TESTS
testImplementation libs.tests.junit

View file

@ -42,6 +42,10 @@
android:name="android.permission.WRITE_CALENDAR"
tools:node="remove" />
<!-- Location Sharing -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- Jitsi SDK is now API23+ -->
<uses-sdk tools:overrideLibrary="org.jitsi.meet.sdk,com.oney.WebRTCModule,com.learnium.RNDeviceInfo,com.reactnativecommunity.asyncstorage,com.ocetnik.timer,com.calendarevents,com.reactnativecommunity.netinfo,com.kevinresol.react_native_default_preference,com.rnimmersive,com.corbt.keepawake,com.BV.LinearGradient,com.horcrux.svg,com.oblador.performance,com.reactnativecommunity.slider,com.brentvatne.react" />
@ -333,6 +337,7 @@
<activity android:name=".features.spaces.people.SpacePeopleActivity" />
<activity android:name=".features.spaces.leave.SpaceLeaveAdvancedActivity" />
<activity android:name=".features.poll.create.CreatePollActivity" />
<activity android:name=".features.location.LocationSharingActivity" />
<!-- Services -->

View file

@ -254,6 +254,33 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
</pre>
<ul>
<li>
<b>org.maplibre.gl:android-sdk</b>
<br/>
<b>org.maplibre.gl:android-plugin-annotation-v9</b>
<br/>
BSD 2-Clause License
Copyright (c) 2021 MapLibre contributors
Copyright (c) 2018-2021 MapTiler.com
Copyright (c) 2014-2020 Mapbox
</li>
</ul>
<pre>
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</pre>
<ul>
<li>
<b>textdrawable</b>

View file

@ -36,6 +36,7 @@ import com.airbnb.epoxy.EpoxyController
import com.airbnb.mvrx.Mavericks
import com.facebook.stetho.Stetho
import com.gabrielittner.threetenbp.LazyThreeTen
import com.mapbox.mapboxsdk.Mapbox
import com.vanniktech.emoji.EmojiManager
import com.vanniktech.emoji.google.GoogleEmojiProvider
import dagger.hilt.android.HiltAndroidApp
@ -197,6 +198,9 @@ class VectorApplication :
})
EmojiManager.install(GoogleEmojiProvider())
// Initialize Mapbox before inflating mapViews
Mapbox.getInstance(this)
}
private val startSyncOnFirstStart = object : DefaultLifecycleObserver {

View file

@ -61,6 +61,8 @@ import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment
import im.vector.app.features.home.room.detail.RoomDetailFragment
import im.vector.app.features.home.room.detail.search.SearchFragment
import im.vector.app.features.home.room.list.RoomListFragment
import im.vector.app.features.location.LocationPreviewFragment
import im.vector.app.features.location.LocationSharingFragment
import im.vector.app.features.login.LoginCaptchaFragment
import im.vector.app.features.login.LoginFragment
import im.vector.app.features.login.LoginGenericTextInputFormFragment
@ -939,4 +941,14 @@ interface FragmentModule {
@IntoMap
@FragmentKey(CreatePollFragment::class)
fun bindCreatePollFragment(fragment: CreatePollFragment): Fragment
@Binds
@IntoMap
@FragmentKey(LocationSharingFragment::class)
fun bindLocationSharingFragment(fragment: LocationSharingFragment): Fragment
@Binds
@IntoMap
@FragmentKey(LocationPreviewFragment::class)
fun bindLocationPreviewFragment(fragment: LocationPreviewFragment): Fragment
}

View file

@ -54,6 +54,7 @@ import im.vector.app.features.home.room.detail.upgrade.MigrateRoomViewModel
import im.vector.app.features.home.room.list.RoomListViewModel
import im.vector.app.features.homeserver.HomeServerCapabilitiesViewModel
import im.vector.app.features.invite.InviteUsersToRoomViewModel
import im.vector.app.features.location.LocationSharingViewModel
import im.vector.app.features.login.LoginViewModel
import im.vector.app.features.login2.LoginViewModel2
import im.vector.app.features.login2.created.AccountCreatedViewModel
@ -588,4 +589,9 @@ interface MavericksViewModelModule {
@IntoMap
@MavericksViewModelKey(CreatePollViewModel::class)
fun createPollViewModelFactory(factory: CreatePollViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(LocationSharingViewModel::class)
fun createLocationSharingViewModelFactory(factory: LocationSharingViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
}

View file

@ -30,8 +30,11 @@ import im.vector.app.core.epoxy.onClick
import im.vector.app.core.extensions.setTextOrHide
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
@ -66,6 +69,12 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
@EpoxyAttribute
var time: String? = null
@EpoxyAttribute
var locationData: LocationData? = null
@EpoxyAttribute
var locationPinProvider: LocationPinProvider? = null
@EpoxyAttribute
var movementMethod: MovementMethod? = null
@ -87,6 +96,20 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
holder.bodyDetails.setTextOrHide(bodyDetails?.charSequence)
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)
}
}
}
}
}
override fun unbind(holder: Holder) {
@ -101,5 +124,6 @@ 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)
}
}

View file

@ -183,6 +183,26 @@ fun openMedia(activity: Activity, savedMediaPath: String, mimeType: String) {
activity.safeStartActivity(intent)
}
/**
* Open external location
* @param activity the activity
* @param latitude latitude of the location
* @param longitude longitude of the location
*/
fun openLocation(activity: Activity, latitude: Double, longitude: Double) {
val locationUri = buildString {
append("geo:")
append(latitude)
append(",")
append(longitude)
append("?q=") // This is required to drop a pin to the location
append(latitude)
append(",")
append(longitude)
}
openUri(activity, locationUri)
}
fun shareMedia(context: Context, file: File, mediaMimeType: String?) {
val mediaUri = try {
FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileProvider", file)

View file

@ -40,6 +40,7 @@ val PERMISSIONS_FOR_MEMBERS_SEARCH = listOf(Manifest.permission.READ_CONTACTS)
val PERMISSIONS_FOR_ROOM_AVATAR = listOf(Manifest.permission.CAMERA)
val PERMISSIONS_FOR_WRITING_FILES = listOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)
val PERMISSIONS_FOR_PICKING_CONTACT = listOf(Manifest.permission.READ_CONTACTS)
val PERMISSIONS_FOR_LOCATION_SHARING = listOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
val PERMISSIONS_EMPTY = emptyList<String>()

View file

@ -37,6 +37,7 @@ import androidx.core.view.isVisible
import im.vector.app.R
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.utils.PERMISSIONS_EMPTY
import im.vector.app.core.utils.PERMISSIONS_FOR_LOCATION_SHARING
import im.vector.app.core.utils.PERMISSIONS_FOR_PICKING_CONTACT
import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.app.databinding.ViewAttachmentTypeSelectorBinding
@ -71,6 +72,7 @@ class AttachmentTypeSelectorView(context: Context,
views.attachmentStickersButton.configure(Type.STICKER)
views.attachmentContactButton.configure(Type.CONTACT)
views.attachmentPollButton.configure(Type.POLL)
views.attachmentLocationButton.configure(Type.LOCATION)
width = LinearLayout.LayoutParams.MATCH_PARENT
height = LinearLayout.LayoutParams.WRAP_CONTENT
animationStyle = 0
@ -129,6 +131,7 @@ class AttachmentTypeSelectorView(context: Context,
Type.STICKER -> views.attachmentStickersButton
Type.CONTACT -> views.attachmentContactButton
Type.POLL -> views.attachmentPollButton
Type.LOCATION -> views.attachmentLocationButton
}.let {
it.isVisible = isVisible
}
@ -211,6 +214,7 @@ class AttachmentTypeSelectorView(context: Context,
FILE(PERMISSIONS_EMPTY, R.string.tooltip_attachment_file),
STICKER(PERMISSIONS_EMPTY, R.string.tooltip_attachment_sticker),
CONTACT(PERMISSIONS_FOR_PICKING_CONTACT, R.string.tooltip_attachment_contact),
POLL(PERMISSIONS_EMPTY, R.string.tooltip_attachment_poll)
POLL(PERMISSIONS_EMPTY, R.string.tooltip_attachment_poll),
LOCATION(PERMISSIONS_FOR_LOCATION_SHARING, R.string.tooltip_attachment_location)
}
}

View file

@ -20,6 +20,7 @@ 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
@ -111,4 +112,7 @@ sealed class RoomDetailAction : VectorViewModelAction {
// Poll
data class EndPoll(val eventId: String) : RoomDetailAction()
// Location
data class ShowLocation(val locationData: LocationData, val userId: String) : RoomDetailAction()
}

View file

@ -106,6 +106,7 @@ import im.vector.app.core.utils.createUIHandler
import im.vector.app.core.utils.isValidUrl
import im.vector.app.core.utils.onPermissionDeniedDialog
import im.vector.app.core.utils.onPermissionDeniedSnackbar
import im.vector.app.core.utils.openLocation
import im.vector.app.core.utils.openUrlInExternalBrowser
import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.core.utils.safeStartActivity
@ -170,6 +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.media.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.notifications.NotificationDrawerManager
@ -212,6 +215,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageFormat
import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
@ -477,6 +481,7 @@ class RoomDetailFragment @Inject constructor(
RoomDetailViewEvents.StopChatEffects -> handleStopChatEffects()
is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it)
RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement()
is RoomDetailViewEvents.ShowLocation -> handleShowLocationPreview(it)
}.exhaustive
}
@ -608,6 +613,17 @@ class RoomDetailFragment @Inject constructor(
}
}
private fun handleShowLocationPreview(viewEvent: RoomDetailViewEvents.ShowLocation) {
navigator
.openLocationSharing(
context = requireContext(),
roomId = roomDetailArgs.roomId,
mode = LocationSharingMode.PREVIEW,
initialLocationData = viewEvent.locationData,
locationOwnerId = viewEvent.userId
)
}
private fun requestNativeWidgetPermission(it: RoomDetailViewEvents.RequestNativeWidgetPermission) {
val tag = RoomWidgetPermissionBottomSheet::class.java.name
val dFrag = childFragmentManager.findFragmentByTag(tag) as? RoomWidgetPermissionBottomSheet
@ -1373,6 +1389,7 @@ class RoomDetailFragment @Inject constructor(
override fun onAddAttachment() {
if (!::attachmentTypeSelector.isInitialized) {
attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@RoomDetailFragment)
attachmentTypeSelector.setAttachmentVisibility(AttachmentTypeSelectorView.Type.LOCATION, vectorPreferences.isLocationSharingEnabled())
}
attachmentTypeSelector.show(views.composerLayout.views.attachmentButton)
}
@ -1920,9 +1937,14 @@ class RoomDetailFragment @Inject constructor(
}
private fun onShareActionClicked(action: EventSharedAction.Share) {
if (action.messageContent is MessageTextContent) {
shareText(requireContext(), action.messageContent.body)
} else if (action.messageContent is MessageWithAttachmentContent) {
when (action.messageContent) {
is MessageTextContent -> shareText(requireContext(), action.messageContent.body)
is MessageLocationContent -> {
LocationData.create(action.messageContent.getUri())?.let {
openLocation(requireActivity(), it.latitude, it.longitude)
}
}
is MessageWithAttachmentContent -> {
lifecycleScope.launch {
val result = runCatching { session.fileService().downloadFile(messageContent = action.messageContent) }
if (!isAdded) return@launch
@ -1933,6 +1955,7 @@ class RoomDetailFragment @Inject constructor(
}
}
}
}
private val saveActionActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
if (allGranted) {
@ -2236,6 +2259,16 @@ class RoomDetailFragment @Inject constructor(
AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher)
AttachmentTypeSelectorView.Type.STICKER -> roomDetailViewModel.handle(RoomDetailAction.SelectStickerAttachment)
AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), roomDetailArgs.roomId, null, PollMode.CREATE)
AttachmentTypeSelectorView.Type.LOCATION -> {
navigator
.openLocationSharing(
context = requireContext(),
roomId = roomDetailArgs.roomId,
mode = LocationSharingMode.STATIC_SHARING,
initialLocationData = null,
locationOwnerId = session.myUserId
)
}
}.exhaustive
}

View file

@ -20,6 +20,7 @@ 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
@ -82,4 +83,6 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
data class StartChatEffect(val type: ChatEffect) : RoomDetailViewEvents()
object StopChatEffects : RoomDetailViewEvents()
object RoomReplacementStarted : RoomDetailViewEvents()
data class ShowLocation(val locationData: LocationData, val userId: String) : RoomDetailViewEvents()
}

View file

@ -53,6 +53,7 @@ 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
@ -384,9 +385,14 @@ class RoomDetailViewModel @AssistedInject constructor(
_viewEvents.post(RoomDetailViewEvents.OpenRoom(action.replacementRoomId, closeCurrentRoom = true))
}
is RoomDetailAction.EndPoll -> handleEndPoll(action.eventId)
is RoomDetailAction.ShowLocation -> handleShowLocation(action.locationData, action.userId)
}.exhaustive
}
private fun handleShowLocation(locationData: LocationData, userId: String) {
_viewEvents.post(RoomDetailViewEvents.ShowLocation(locationData, userId))
}
private fun handleJitsiCallJoinStatus(action: RoomDetailAction.UpdateJoinJitsiCallStatus) = withState { state ->
if (state.jitsiState.confId == null) {
// If jitsi widget is removed while on the call

View file

@ -33,15 +33,19 @@ import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.format.EventDetailsFormatter
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData
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.media.ImageContentRenderer
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent
import org.matrix.android.sdk.api.session.room.send.SendState
import javax.inject.Inject
@ -57,7 +61,8 @@ class MessageActionsEpoxyController @Inject constructor(
private val errorFormatter: ErrorFormatter,
private val spanUtils: SpanUtils,
private val eventDetailsFormatter: EventDetailsFormatter,
private val dateFormatter: VectorDateFormatter
private val dateFormatter: VectorDateFormatter,
private val locationPinProvider: LocationPinProvider
) : TypedEpoxyController<MessageActionState>() {
var listener: MessageActionsEpoxyControllerListener? = null
@ -69,6 +74,9 @@ 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())
}
bottomSheetMessagePreviewItem {
id("preview")
avatarRenderer(host.avatarRenderer)
@ -81,6 +89,8 @@ class MessageActionsEpoxyController @Inject constructor(
body(body.toEpoxyCharSequence())
bodyDetails(host.eventDetailsFormatter.format(state.timelineEvent()?.root)?.toEpoxyCharSequence())
time(formattedDate)
locationData(locationData)
locationPinProvider(host.locationPinProvider)
}
// Send state

View file

@ -424,7 +424,8 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_FILE,
MessageType.MSGTYPE_POLL_START -> true
MessageType.MSGTYPE_POLL_START,
MessageType.MSGTYPE_LOCATION -> true
else -> false
}
}

View file

@ -33,10 +33,12 @@ 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
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvider
import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
@ -49,6 +51,8 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem_
import im.vector.app.features.home.room.detail.timeline.item.MessageImageVideoItem
import im.vector.app.features.home.room.detail.timeline.item.MessageImageVideoItem_
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.MessageLocationItem
import im.vector.app.features.home.room.detail.timeline.item.MessageLocationItem_
import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem
import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem_
import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem
@ -67,8 +71,10 @@ 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.media.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer
import im.vector.app.features.settings.VectorPreferences
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import me.gujun.android.span.span
import org.commonmark.node.Document
@ -83,6 +89,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithF
import org.matrix.android.sdk.api.session.room.model.message.MessageEmoteContent
import org.matrix.android.sdk.api.session.room.model.message.MessageFileContent
import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent
import org.matrix.android.sdk.api.session.room.model.message.MessageNoticeContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
@ -118,7 +125,9 @@ class MessageItemFactory @Inject constructor(
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val spanUtils: SpanUtils,
private val session: Session,
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker) {
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker,
private val locationPinProvider: LocationPinProvider,
private val vectorPreferences: VectorPreferences) {
// TODO inject this properly?
private var roomId: String = ""
@ -170,12 +179,45 @@ class MessageItemFactory @Inject constructor(
}
}
is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessagePollContent -> buildPollContent(messageContent, informationData, highlight, callback, attributes)
is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes)
is MessageLocationContent -> {
if (vectorPreferences.labsRenderLocationsInTimeline()) {
buildLocationItem(messageContent, informationData, highlight, callback, attributes)
} else {
buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes)
}
}
else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes)
}
}
private fun buildPollContent(pollContent: MessagePollContent,
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 mapCallback: MessageLocationItem.Callback = object : MessageLocationItem.Callback {
override fun onMapClicked() {
locationData?.let {
callback?.onTimelineItemAction(RoomDetailAction.ShowLocation(it, informationData.senderId))
}
}
}
return MessageLocationItem_()
.attributes(attributes)
.locationData(locationData)
.userId(informationData.senderId)
.locationPinProvider(locationPinProvider)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
.callback(mapCallback)
}
private fun buildPollItem(pollContent: MessagePollContent,
informationData: MessageInformationData,
highlight: Boolean,
callback: TimelineEventController.Callback?,

View file

@ -89,6 +89,9 @@ class DisplayableEventFormatter @Inject constructor(
MessageType.MSGTYPE_FILE -> {
simpleFormat(senderName, stringProvider.getString(R.string.sent_a_file), appendAuthor)
}
MessageType.MSGTYPE_LOCATION -> {
simpleFormat(senderName, stringProvider.getString(R.string.sent_location), appendAuthor)
}
else -> {
simpleFormat(senderName, messageContent.body, appendAuthor)
}

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2021 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.home.room.detail.timeline.helper
import android.content.Context
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import androidx.core.content.ContextCompat
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.transition.Transition
import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
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 javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LocationPinProvider @Inject constructor(
private val context: Context,
private val activeSessionHolder: ActiveSessionHolder,
private val dimensionConverter: DimensionConverter,
private val avatarRenderer: AvatarRenderer
) {
private val cache = mutableMapOf<String, Drawable>()
private val glideRequests by lazy {
GlideApp.with(context)
}
fun create(userId: String, callback: (Drawable) -> Unit) {
if (cache.contains(userId)) {
callback(cache[userId]!!)
return
}
activeSessionHolder.getActiveSession().getUser(userId)?.toMatrixItem()?.let {
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)
}
override fun onLoadCleared(placeholder: Drawable?) {
// Is it possible? Put placeholder instead?
}
})
}
}
}

View file

@ -0,0 +1,83 @@
/*
* Copyright (c) 2021 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.home.room.detail.timeline.item
import android.widget.FrameLayout
import androidx.constraintlayout.widget.ConstraintLayout
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.onClick
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
@EpoxyAttribute
var userId: String? = null
@EpoxyAttribute
var locationPinProvider: LocationPinProvider? = null
override fun bind(holder: Holder) {
super.bind(holder)
renderSendState(holder.mapViewContainer, null)
val location = locationData ?: return
val locationOwnerId = userId ?: return
holder.clickableMapArea.onClick {
callback?.onMapClicked()
}
holder.mapView.apply {
initialize {
zoomToLocation(location.latitude, location.longitude, INITIAL_ZOOM)
locationPinProvider?.create(locationOwnerId) { pinDrawable ->
addPinToMap(locationOwnerId, pinDrawable)
updatePinLocation(locationOwnerId, location.latitude, location.longitude)
}
}
}
}
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)
}
companion object {
private const val STUB_ID = R.id.messageContentLocationStub
private const val INITIAL_ZOOM = 15.0
}
}

View file

@ -0,0 +1,21 @@
/*
* 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
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

View file

@ -0,0 +1,57 @@
/*
* Copyright (c) 2021 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.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class LocationData(
val latitude: Double,
val longitude: Double,
val uncertainty: Double?
) : 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
}
}
}

View file

@ -0,0 +1,94 @@
/*
* Copyright (c) 2021 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.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.args
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 javax.inject.Inject
class LocationPreviewFragment @Inject constructor(
private val locationPinProvider: LocationPinProvider
) : VectorBaseFragment<FragmentLocationPreviewBinding>() {
private val args: LocationSharingArgs by args()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationPreviewBinding {
return FragmentLocationPreviewBinding.inflate(layoutInflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.mapView.initialize {
if (isAdded) {
onMapReady()
}
}
}
override fun onPause() {
views.mapView.onPause()
super.onPause()
}
override fun onStop() {
views.mapView.onStop()
super.onStop()
}
override fun getMenuRes() = R.menu.menu_location_preview
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.share_external -> {
onShareLocationExternal()
return true
}
}
return super.onOptionsItemSelected(item)
}
private fun onShareLocationExternal() {
val location = args.initialLocationData ?: return
openLocation(requireActivity(), location.latitude, location.longitude)
}
private fun onMapReady() {
if (!isAdded) return
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)
}
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2021 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 im.vector.app.core.platform.VectorViewModelAction
sealed class LocationSharingAction : VectorViewModelAction {
data class OnLocationUpdate(val locationData: LocationData) : LocationSharingAction()
object OnShareLocation : LocationSharingAction()
object OnLocationProviderIsNotAvailable : LocationSharingAction()
}

View file

@ -0,0 +1,81 @@
/*
* Copyright (c) 2021 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.Context
import android.content.Intent
import android.os.Parcelable
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.core.extensions.addFragment
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivityLocationSharingBinding
import kotlinx.parcelize.Parcelize
@Parcelize
data class LocationSharingArgs(
val roomId: String,
val mode: LocationSharingMode,
val initialLocationData: LocationData?,
val locationOwnerId: String
) : Parcelable
@AndroidEntryPoint
class LocationSharingActivity : VectorBaseActivity<ActivityLocationSharingBinding>() {
override fun getBinding() = ActivityLocationSharingBinding.inflate(layoutInflater)
override fun initUiAndData() {
val locationSharingArgs: LocationSharingArgs? = intent?.extras?.getParcelable(EXTRA_LOCATION_SHARING_ARGS)
if (locationSharingArgs == null) {
finish()
return
}
setupToolbar(views.toolbar)
.setTitle(locationSharingArgs.mode.titleRes)
.allowBack()
if (isFirstCreation()) {
when (locationSharingArgs.mode) {
LocationSharingMode.STATIC_SHARING -> {
addFragment(
views.fragmentContainer,
LocationSharingFragment::class.java,
locationSharingArgs
)
}
LocationSharingMode.PREVIEW -> {
addFragment(
views.fragmentContainer,
LocationPreviewFragment::class.java,
locationSharingArgs
)
}
}
}
}
companion object {
private const val EXTRA_LOCATION_SHARING_ARGS = "EXTRA_LOCATION_SHARING_ARGS"
fun getIntent(context: Context, locationSharingArgs: LocationSharingArgs): Intent {
return Intent(context, LocationSharingActivity::class.java).apply {
putExtra(EXTRA_LOCATION_SHARING_ARGS, locationSharingArgs)
}
}
}
}

View file

@ -0,0 +1,127 @@
/*
* Copyright (c) 2021 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.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.airbnb.mvrx.fragmentViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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 javax.inject.Inject
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 viewModel: LocationSharingViewModel by fragmentViewModel()
private var lastZoomValue: Double = -1.0
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLocationSharingBinding {
return FragmentLocationSharingBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
views.mapView.initialize {
if (isAdded) {
onMapReady()
}
}
views.shareLocationContainer.debouncedClicks {
viewModel.handle(LocationSharingAction.OnShareLocation)
}
viewModel.observeViewEvents {
when (it) {
LocationSharingViewEvents.LocationNotAvailableError -> handleLocationNotAvailableError()
LocationSharingViewEvents.Close -> activity?.finish()
}.exhaustive
}
}
override fun onPause() {
views.mapView.onPause()
super.onPause()
}
override fun onStop() {
views.mapView.onStop()
super.onStop()
}
override fun onDestroy() {
locationTracker.stop()
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)
.setMessage(R.string.location_not_available_dialog_content)
.setPositiveButton(R.string.ok) { _, _ ->
activity?.finish()
}
.show()
}
companion object {
const val USER_PIN_NAME = "USER_PIN_NAME"
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2021 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 im.vector.app.core.platform.VectorViewEvents
sealed class LocationSharingViewEvents : VectorViewEvents {
object Close : LocationSharingViewEvents()
object LocationNotAvailableError : LocationSharingViewEvents()
}

View file

@ -0,0 +1,74 @@
/*
* Copyright (c) 2021 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 com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
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 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 room = session.getRoom(initialState.roomId)!!
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<LocationSharingViewModel, LocationSharingViewState> {
override fun create(initialState: LocationSharingViewState): LocationSharingViewModel
}
companion object : MavericksViewModelFactory<LocationSharingViewModel, LocationSharingViewState> by hiltMavericksViewModelFactory() {
}
override fun handle(action: LocationSharingAction) {
when (action) {
is LocationSharingAction.OnLocationUpdate -> handleLocationUpdate(action.locationData)
LocationSharingAction.OnShareLocation -> handleShareLocation()
LocationSharingAction.OnLocationProviderIsNotAvailable -> handleLocationProviderIsNotAvailable()
}.exhaustive
}
private fun handleShareLocation() = withState { state ->
state.lastKnownLocation?.let { location ->
room.sendLocation(
latitude = location.latitude,
longitude = location.longitude,
uncertainty = location.uncertainty
)
_viewEvents.post(LocationSharingViewEvents.Close)
} ?: run {
_viewEvents.post(LocationSharingViewEvents.LocationNotAvailableError)
}
}
private fun handleLocationUpdate(locationData: LocationData) {
setState {
copy(lastKnownLocation = locationData)
}
}
private fun handleLocationProviderIsNotAvailable() {
_viewEvents.post(LocationSharingViewEvents.LocationNotAvailableError)
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2021 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 androidx.annotation.StringRes
import com.airbnb.mvrx.MavericksState
import im.vector.app.R
enum class LocationSharingMode(@StringRes val titleRes: Int) {
STATIC_SHARING(R.string.location_activity_title_static_sharing),
PREVIEW(R.string.location_activity_title_preview)
}
data class LocationSharingViewState(
val roomId: String,
val mode: LocationSharingMode,
val lastKnownLocation: LocationData? = null
) : MavericksState {
constructor(locationSharingArgs: LocationSharingArgs) : this(
roomId = locationSharingArgs.roomId,
mode = locationSharingArgs.mode
)
}

View file

@ -0,0 +1,89 @@
/*
* Copyright (c) 2021 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.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 timber.log.Timber
import javax.inject.Inject
class LocationTracker @Inject constructor(
private val context: Context
) : LocationListener {
interface Callback {
fun onLocationUpdate(locationData: LocationData)
fun onLocationProviderIsNotAvailable()
}
private var locationManager: LocationManager? = null
var callback: Callback? = null
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun start() {
val locationManager = context.getSystemService<LocationManager>()
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 {
callback?.onLocationProviderIsNotAvailable()
Timber.v("## LocationTracker. LocationManager is not available")
}
}
@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION])
fun stop() {
locationManager?.removeUpdates(this)
callback = null
}
override fun onLocationChanged(location: Location) {
callback?.onLocationUpdate(location.toLocationData())
}
private fun Location.toLocationData(): LocationData {
return LocationData(latitude, longitude, accuracy.toDouble())
}
}

View file

@ -0,0 +1,91 @@
/*
* Copyright (c) 2021 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.Context
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import com.mapbox.mapboxsdk.camera.CameraPosition
import com.mapbox.mapboxsdk.geometry.LatLng
import com.mapbox.mapboxsdk.maps.MapView
import com.mapbox.mapboxsdk.maps.MapboxMap
import com.mapbox.mapboxsdk.maps.Style
import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager
import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions
import com.mapbox.mapboxsdk.style.layers.Property
import im.vector.app.BuildConfig
class MapTilerMapView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : MapView(context, attrs, defStyleAttr), VectorMapView {
private var map: MapboxMap? = null
private var symbolManager: SymbolManager? = null
private var style: Style? = null
override fun initialize(onMapReady: () -> Unit) {
getMapAsync { map ->
map.setStyle(styleUrl) { style ->
this.symbolManager = SymbolManager(this, map, style)
this.map = map
this.style = style
onMapReady()
}
}
}
override fun addPinToMap(pinId: String, image: Drawable) {
style?.addImage(pinId, image)
}
override fun updatePinLocation(pinId: String, latitude: Double, longitude: Double) {
symbolManager?.create(
SymbolOptions()
.withLatLng(LatLng(latitude, longitude))
.withIconImage(pinId)
.withIconAnchor(Property.ICON_ANCHOR_BOTTOM)
)
}
override fun deleteAllPins() {
symbolManager?.deleteAll()
}
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
}
}
companion object {
private const val styleUrl = "https://api.maptiler.com/maps/streets/style.json?key=${BuildConfig.mapTilerKey}"
}
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2021 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.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)
}

View file

@ -58,6 +58,10 @@ import im.vector.app.features.home.room.detail.search.SearchActivity
import im.vector.app.features.home.room.detail.search.SearchArgs
import im.vector.app.features.home.room.filtered.FilteredRoomsActivity
import im.vector.app.features.invite.InviteUsersToRoomActivity
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.LocationSharingActivity
import im.vector.app.features.location.LocationSharingArgs
import im.vector.app.features.location.LocationSharingMode
import im.vector.app.features.login.LoginActivity
import im.vector.app.features.login.LoginConfig
import im.vector.app.features.matrixto.MatrixToBottomSheet
@ -533,6 +537,18 @@ class DefaultNavigator @Inject constructor(
context.startActivity(intent)
}
override fun openLocationSharing(context: Context,
roomId: String,
mode: LocationSharingMode,
initialLocationData: LocationData?,
locationOwnerId: String) {
val intent = LocationSharingActivity.getIntent(
context,
LocationSharingArgs(roomId = roomId, mode = mode, initialLocationData = initialLocationData, locationOwnerId = locationOwnerId)
)
context.startActivity(intent)
}
private fun startActivity(context: Context, intent: Intent, buildTask: Boolean) {
if (buildTask) {
val stackBuilder = TaskStackBuilder.create(context)

View file

@ -25,6 +25,8 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.core.util.Pair
import im.vector.app.features.crypto.recover.SetupMode
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.location.LocationData
import im.vector.app.features.location.LocationSharingMode
import im.vector.app.features.login.LoginConfig
import im.vector.app.features.media.AttachmentData
import im.vector.app.features.pin.PinMode
@ -150,4 +152,10 @@ interface Navigator {
fun openCallTransfer(context: Context, callId: String)
fun openCreatePoll(context: Context, roomId: String, editedEventId: String?, mode: PollMode)
fun openLocationSharing(context: Context,
roomId: String,
mode: LocationSharingMode,
initialLocationData: LocationData?,
locationOwnerId: String)
}

View file

@ -187,6 +187,9 @@ class VectorPreferences @Inject constructor(private val context: Context) {
private const val DID_ASK_TO_ENABLE_SESSION_PUSH = "DID_ASK_TO_ENABLE_SESSION_PUSH"
private const val DID_PROMOTE_NEW_RESTRICTED_JOIN_RULE = "DID_PROMOTE_NEW_RESTRICTED_JOIN_RULE"
// Location Sharing
const val SETTINGS_PREF_ENABLE_LOCATION_SHARING = "SETTINGS_PREF_ENABLE_LOCATION_SHARING"
private const val MEDIA_SAVING_3_DAYS = 0
private const val MEDIA_SAVING_1_WEEK = 1
private const val MEDIA_SAVING_1_MONTH = 2
@ -196,6 +199,8 @@ class VectorPreferences @Inject constructor(private val context: Context) {
private const val TAKE_PHOTO_VIDEO_MODE = "TAKE_PHOTO_VIDEO_MODE"
private const val SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE = "SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE"
// Possible values for TAKE_PHOTO_VIDEO_MODE
const val TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK = 0
const val TAKE_PHOTO_VIDEO_MODE_PHOTO = 1
@ -989,4 +994,12 @@ class VectorPreferences @Inject constructor(private val context: Context) {
putInt(TAKE_PHOTO_VIDEO_MODE, mode)
}
}
fun isLocationSharingEnabled(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_PREF_ENABLE_LOCATION_SHARING, false) && BuildConfig.enableLocationSharing
}
fun labsRenderLocationsInTimeline(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE, true)
}
}

View file

@ -22,6 +22,7 @@ import android.widget.CheckedTextView
import androidx.core.view.children
import androidx.preference.Preference
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.dialogs.PhotoOrVideoDialog
import im.vector.app.core.extensions.restart
@ -149,6 +150,8 @@ class VectorSettingsPreferencesFragment @Inject constructor(
})
true
}
findPreference<VectorSwitchPreference>(VectorPreferences.SETTINGS_PREF_ENABLE_LOCATION_SHARING)?.isVisible = BuildConfig.enableLocationSharing
}
private fun updateTakePhotoOrVideoPreferenceSummary() {

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="51dp"
android:height="55dp"
android:viewportWidth="51"
android:viewportHeight="55">
<path
android:pathData="M29.1957,50.7341C41.5276,48.9438 51,38.3281 51,25.5C51,11.4167 39.5833,0 25.5,0C11.4167,0 0,11.4167 0,25.5C0,38.3282 9.4725,48.9439 21.8045,50.7342L25.5001,54.2903L29.1957,50.7341Z"
android:fillColor="#0DBD8B"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="14dp"
android:height="20dp"
android:viewportWidth="14"
android:viewportHeight="20">
<path
android:pathData="M7,0C3.13,0 0,3.2152 0,7.1905C0,11.4741 4.42,17.3806 6.24,19.6302C6.64,20.1233 7.37,20.1233 7.77,19.6302C9.58,17.3806 14,11.4741 14,7.1905C14,3.2152 10.87,0 7,0ZM7,9.7586C5.62,9.7586 4.5,8.6081 4.5,7.1905C4.5,5.773 5.62,4.6225 7,4.6225C8.38,4.6225 9.5,5.773 9.5,7.1905C9.5,8.6081 8.38,9.7586 7,9.7586Z"
android:fillColor="#FFFFFF"/>
</vector>

View file

@ -0,0 +1,5 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#0DBD8B" android:fillType="evenOdd" android:pathData="M21.768,2.8608V3.0305C21.7809,3.0862 21.7889,3.143 21.792,3.2002V8.4608C21.792,8.718 21.6908,8.9646 21.5108,9.1465C21.3308,9.3283 21.0866,9.4305 20.832,9.4305C20.5774,9.4305 20.3332,9.3283 20.1531,9.1465C19.9731,8.9646 19.872,8.718 19.872,8.4608V5.5517L11.256,14.2547C11.1661,14.3455 11.0595,14.4174 10.9421,14.4665C10.8248,14.5156 10.699,14.5409 10.572,14.5409C10.4449,14.5409 10.3192,14.5156 10.2018,14.4665C10.0844,14.4174 9.9778,14.3455 9.888,14.2547C9.7982,14.164 9.7269,14.0563 9.6783,13.9377C9.6297,13.8192 9.6046,13.6921 9.6046,13.5638C9.6046,13.4355 9.6297,13.3085 9.6783,13.1899C9.7269,13.0714 9.7982,12.9636 9.888,12.8729L18.504,4.2426H15.624C15.4979,4.2426 15.3731,4.2175 15.2566,4.1688C15.1401,4.1201 15.0343,4.0486 14.9451,3.9586C14.856,3.8686 14.7853,3.7617 14.737,3.644C14.6888,3.5264 14.664,3.4003 14.664,3.2729C14.664,3.1456 14.6888,3.0195 14.737,2.9018C14.7853,2.7842 14.856,2.6773 14.9451,2.5872C15.0343,2.4972 15.1401,2.4258 15.2566,2.377C15.3731,2.3283 15.4979,2.3032 15.624,2.3032H21.192L21.288,2.3517L21.36,2.4002L21.552,2.5457L21.672,2.6911L21.72,2.7638L21.768,2.8608ZM16.464,22.0122H5.088C4.3242,22.0122 3.5917,21.7057 3.0515,21.1602C2.5114,20.6146 2.208,19.8747 2.208,19.1031V7.6122C2.208,6.8407 2.5114,6.1007 3.0515,5.5552C3.5917,5.0096 4.3242,4.7031 5.088,4.7031H11.88C12.1346,4.7031 12.3788,4.8053 12.5588,4.9871C12.7389,5.169 12.84,5.4156 12.84,5.6728C12.84,5.93 12.7389,6.1767 12.5588,6.3585C12.3788,6.5403 12.1346,6.6425 11.88,6.6425H5.088C4.8334,6.6425 4.5892,6.7447 4.4092,6.9265C4.2291,7.1084 4.128,7.355 4.128,7.6122V19.1031C4.128,19.3603 4.2291,19.607 4.4092,19.7888C4.5892,19.9707 4.8334,20.0728 5.088,20.0728H16.464C16.7186,20.0728 16.9628,19.9707 17.1428,19.7888C17.3229,19.607 17.424,19.3603 17.424,19.1031V12.2425C17.424,11.9853 17.5252,11.7387 17.7052,11.5568C17.8852,11.375 18.1294,11.2728 18.384,11.2728C18.6386,11.2728 18.8828,11.375 19.0628,11.5568C19.2429,11.7387 19.344,11.9853 19.344,12.2425V19.1031C19.344,19.8747 19.0406,20.6146 18.5005,21.1602C17.9604,21.7057 17.2278,22.0122 16.464,22.0122Z"/>
</vector>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:id="@+id/fragmentContainer"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<im.vector.app.features.location.MapTilerMapView
android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:mapbox_renderTextureMode="true" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<im.vector.app.features.location.MapTilerMapView
android:id="@+id/mapView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:mapbox_renderTextureMode="true" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/shareLocationContainer"
android:layout_width="0dp"
android:layout_height="72dp"
android:background="?android:colorBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<ImageView
android:id="@+id/shareLocationImageView"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="12dp"
android:background="@drawable/circle"
android:backgroundTint="?colorPrimary"
android:contentDescription="@string/a11y_location_share_icon"
android:padding="10dp"
android:src="@drawable/ic_attachment_location_white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
style="@style/TextAppearance.Vector.Subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/location_share"
android:textColor="?colorPrimary"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/shareLocationImageView"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -103,4 +103,18 @@
tools:text="1080 x 1024 - 43s - 12kB"
tools:visibility="visible" />
<im.vector.app.features.location.MapTilerMapView
android:id="@+id/bottom_sheet_message_preview_location"
android:layout_width="0dp"
android:layout_height="200dp"
android:layout_marginTop="6dp"
android:contentDescription="@string/attachment_type_location"
android:scaleType="centerCrop"
android:visibility="gone"
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" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -130,6 +130,11 @@
style="@style/TimelineContentStubBaseParams"
android:layout="@layout/item_timeline_event_poll" />
<ViewStub
android:id="@+id/messageContentLocationStub"
style="@style/TimelineContentStubBaseParams"
android:layout="@layout/item_timeline_event_location_stub" />
</FrameLayout>
<im.vector.app.core.ui.views.SendStateImageView

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/mapViewContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<im.vector.app.features.location.MapTilerMapView
android:id="@+id/mapView"
android:layout_width="0dp"
android:layout_height="200dp"
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>
</androidx.cardview.widget.CardView>

View file

@ -72,6 +72,16 @@
android:src="@drawable/ic_attachment_poll"
app:tint="?colorPrimary" />
<ImageButton
android:id="@+id/attachmentLocationButton"
android:layout_width="@dimen/layout_touch_size"
android:layout_height="@dimen/layout_touch_size"
android:layout_marginStart="2dp"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/attachment_type_location"
android:src="@drawable/ic_attachment_location"
app:tint="?colorPrimary" />
<ImageButton
android:id="@+id/attachmentCameraButton"
android:layout_width="@dimen/layout_touch_size"

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/share_external"
android:icon="@drawable/ic_share_external"
android:title="@string/location_share_external"
app:iconTint="?colorPrimary"
app:showAsAction="always" />
</menu>

View file

@ -2454,6 +2454,7 @@
<string name="attachment_type_gallery">"Gallery"</string>
<string name="attachment_type_sticker">"Sticker"</string>
<string name="attachment_type_poll">Poll</string>
<string name="attachment_type_location">Location</string>
<string name="rotate_and_crop_screen_title">Rotate and crop</string>
<string name="error_handling_incoming_share">Couldn\'t handle share data</string>
@ -2769,6 +2770,7 @@
<string name="sent_a_poll">Poll</string>
<string name="sent_a_reaction">Reacted with: %s</string>
<string name="sent_verification_conclusion">Verification Conclusion</string>
<string name="sent_location">Shared their location</string>
<string name="verification_request_waiting">Waiting…</string>
<string name="verification_request_other_cancelled">%s cancelled</string>
@ -3721,11 +3723,24 @@
<string name="closed_poll_option_title">Closed poll</string>
<string name="closed_poll_option_description">Results are only revealed when you end the poll</string>
<!-- Location -->
<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="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>
<string name="location_share_external">Open with</string>
<string name="settings_enable_location_sharing">Enable location sharing</string>
<string name="settings_enable_location_sharing_summary">Once enabled you will be able to send your location to any room</string>
<string name="labs_render_locations_in_timeline">Render user locations in the timeline</string>
<string name="tooltip_attachment_photo">Open camera</string>
<string name="tooltip_attachment_gallery">Send images and videos</string>
<string name="tooltip_attachment_file">Upload file</string>
<string name="tooltip_attachment_sticker">Send sticker</string>
<string name="tooltip_attachment_contact">Open contacts</string>
<string name="tooltip_attachment_poll">Create poll</string>
<string name="tooltip_attachment_location">Share location</string>
</resources>

View file

@ -57,4 +57,9 @@
android:summary="@string/labs_auto_report_uisi_desc"
android:title="@string/labs_auto_report_uisi" />
<im.vector.app.core.preference.VectorSwitchPreference
android:defaultValue="true"
android:key="SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE"
android:title="@string/labs_render_locations_in_timeline" />
</androidx.preference.PreferenceScreen>

View file

@ -72,6 +72,12 @@
android:title="@string/option_take_photo_video"
tools:summary="@string/option_always_ask" />
<im.vector.app.core.preference.VectorSwitchPreference
android:defaultValue="false"
android:key="SETTINGS_PREF_ENABLE_LOCATION_SHARING"
android:summary="@string/settings_enable_location_sharing_summary"
android:title="@string/settings_enable_location_sharing" />
</im.vector.app.core.preference.VectorPreferenceCategory>
<im.vector.app.core.preference.VectorPreferenceCategory android:title="@string/settings_category_timeline">