diff --git a/changelog.d/2210.bugfix b/changelog.d/2210.bugfix new file mode 100644 index 0000000000..6f7c09ce26 --- /dev/null +++ b/changelog.d/2210.bugfix @@ -0,0 +1 @@ +Static location sharing and rendering \ No newline at end of file diff --git a/dependencies_groups.gradle b/dependencies_groups.gradle index fd36f5110c..7de8100469 100644 --- a/dependencies_groups.gradle +++ b/dependencies_groups.gradle @@ -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', diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt new file mode 100644 index 0000000000..e8b3cf2488 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAsset.kt @@ -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 +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt new file mode 100644 index 0000000000..ef40e21c47 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationAssetType.kt @@ -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 +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationInfo.kt index a76c3c5b64..a1fd3bd2ec 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/LocationInfo.kt @@ -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 -} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt index 6881c09924..2f3db8ff51 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageLocationContent.kt @@ -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 +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt index 5e1b430207..20d00394df 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt @@ -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 diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt index 9d105120e2..5662a72cb8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt @@ -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) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt index 72ae688c4b..1e46602411 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt @@ -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": { diff --git a/vector/build.gradle b/vector/build.gradle index 9203a8ec2f..0151950c84 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -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 diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index b3f845d8c5..fdec5337ba 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -42,6 +42,10 @@ android:name="android.permission.WRITE_CALENDAR" tools:node="remove" /> + + + + @@ -333,6 +337,7 @@ + diff --git a/vector/src/main/assets/open_source_licenses.html b/vector/src/main/assets/open_source_licenses.html index 0eefa3b863..2c25606f57 100755 --- a/vector/src/main/assets/open_source_licenses.html +++ b/vector/src/main/assets/open_source_licenses.html @@ -254,6 +254,33 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +
    +
  • + org.maplibre.gl:android-sdk +
    + org.maplibre.gl:android-plugin-annotation-v9 +
    + BSD 2-Clause License + + Copyright (c) 2021 MapLibre contributors + + Copyright (c) 2018-2021 MapTiler.com + + Copyright (c) 2014-2020 Mapbox +
  • +
+
+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.
+
+
  • textdrawable diff --git a/vector/src/main/java/im/vector/app/VectorApplication.kt b/vector/src/main/java/im/vector/app/VectorApplication.kt index 05d20662c7..d252b5d9bd 100644 --- a/vector/src/main/java/im/vector/app/VectorApplication.kt +++ b/vector/src/main/java/im/vector/app/VectorApplication.kt @@ -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 { diff --git a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt index 626cf90592..5d27909b25 100644 --- a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt @@ -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 } diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index cc31a7dca6..9ad01cd3e4 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -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<*, *> } diff --git a/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt b/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt index 8e61a1c1cb..cdecd2d6c6 100644 --- a/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt +++ b/vector/src/main/java/im/vector/app/core/epoxy/bottomsheet/BottomSheetMessagePreviewItem.kt @@ -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 + 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(R.id.bottom_sheet_message_preview_body_details) val timestamp by bind(R.id.bottom_sheet_message_preview_timestamp) val imagePreview by bind(R.id.bottom_sheet_message_preview_image) + val mapView by bind(R.id.bottom_sheet_message_preview_location) } } diff --git a/vector/src/main/java/im/vector/app/core/utils/ExternalApplicationsUtil.kt b/vector/src/main/java/im/vector/app/core/utils/ExternalApplicationsUtil.kt index 478f4d882b..a9375b6545 100644 --- a/vector/src/main/java/im/vector/app/core/utils/ExternalApplicationsUtil.kt +++ b/vector/src/main/java/im/vector/app/core/utils/ExternalApplicationsUtil.kt @@ -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) diff --git a/vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt b/vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt index 19dc341f12..dabf11b9d3 100644 --- a/vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt +++ b/vector/src/main/java/im/vector/app/core/utils/PermissionsTools.kt @@ -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() diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt index 683c5908ba..a15bd52174 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt @@ -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 @@ -123,12 +125,13 @@ class AttachmentTypeSelectorView(context: Context, fun setAttachmentVisibility(type: Type, isVisible: Boolean) { when (type) { - Type.CAMERA -> views.attachmentCameraButton - Type.GALLERY -> views.attachmentGalleryButton - Type.FILE -> views.attachmentFileButton - Type.STICKER -> views.attachmentStickersButton - Type.CONTACT -> views.attachmentContactButton - Type.POLL -> views.attachmentPollButton + Type.CAMERA -> views.attachmentCameraButton + Type.GALLERY -> views.attachmentGalleryButton + Type.FILE -> views.attachmentFileButton + 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) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index f866bb328d..58e36d2303 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -20,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() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index 72dbf1436f..9926ecad24 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -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,16 +1937,22 @@ 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) { - lifecycleScope.launch { - val result = runCatching { session.fileService().downloadFile(messageContent = action.messageContent) } - if (!isAdded) return@launch - result.fold( - { shareMedia(requireContext(), it, getMimeTypeFromUri(requireContext(), it.toUri())) }, - { showErrorInSnackbar(it) } - ) + 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 + result.fold( + { shareMedia(requireContext(), it, getMimeTypeFromUri(requireContext(), it.toUri())) }, + { showErrorInSnackbar(it) } + ) + } } } } @@ -2225,17 +2248,27 @@ class RoomDetailFragment @Inject constructor( private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) { when (type) { - AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera( + AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera( activity = requireActivity(), vectorPreferences = vectorPreferences, cameraActivityResultLauncher = attachmentCameraActivityResultLauncher, cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher ) - AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) - AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) - 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.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) + AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) + 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 } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt index 86240a5ffe..b0921e01f9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt @@ -20,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() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index 9149ae1dca..6e14b0fc76 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -53,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 diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt index 27819ca863..1ff9679479 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsEpoxyController.kt @@ -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() { 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(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 diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index 8e69e2d932..ea54d91a78 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -424,8 +424,9 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted MessageType.MSGTYPE_VIDEO, MessageType.MSGTYPE_AUDIO, MessageType.MSGTYPE_FILE, - MessageType.MSGTYPE_POLL_START -> true - else -> false + MessageType.MSGTYPE_POLL_START, + MessageType.MSGTYPE_LOCATION -> true + else -> false } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index d6bfc4ece7..eab7621d14 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -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,16 +179,49 @@ 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, - informationData: MessageInformationData, - highlight: Boolean, - callback: TimelineEventController.Callback?, - attributes: AbsMessageItem.Attributes): PollItem? { + 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?, + attributes: AbsMessageItem.Attributes): PollItem? { val optionViewStates = mutableListOf() val pollResponseSummary = informationData.pollResponseAggregatedSummary diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt index 3892bfff85..d5f3a74e4e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt @@ -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) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt new file mode 100644 index 0000000000..fe3a7d9007 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/LocationPinProvider.kt @@ -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() + + 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(size, size) { + override fun onResourceReady(resource: Drawable, transition: Transition?) { + val bgUserPin = ContextCompat.getDrawable(context, R.drawable.bg_map_user_pin)!! + val layerDrawable = LayerDrawable(arrayOf(bgUserPin, resource)) + val horizontalInset = dimensionConverter.dpToPx(4) + val topInset = dimensionConverter.dpToPx(4) + val bottomInset = dimensionConverter.dpToPx(8) + layerDrawable.setLayerInset(1, horizontalInset, topInset, horizontalInset, bottomInset) + + cache[userId] = layerDrawable + + callback(layerDrawable) + } + + override fun onLoadCleared(placeholder: Drawable?) { + // Is it possible? Put placeholder instead? + } + }) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLocationItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLocationItem.kt new file mode 100644 index 0000000000..3f030866a5 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageLocationItem.kt @@ -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() { + + 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(R.id.mapViewContainer) + val mapView by bind(R.id.mapView) + val clickableMapArea by bind(R.id.clickableMapArea) + } + + companion object { + private const val STUB_ID = R.id.messageContentLocationStub + private const val INITIAL_ZOOM = 15.0 + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/Config.kt b/vector/src/main/java/im/vector/app/features/location/Config.kt new file mode 100644 index 0000000000..630df16a37 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/Config.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/location/LocationData.kt b/vector/src/main/java/im/vector/app/features/location/LocationData.kt new file mode 100644 index 0000000000..c3ff09ebcd --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/LocationData.kt @@ -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 + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt new file mode 100644 index 0000000000..6209bf5a4f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/LocationPreviewFragment.kt @@ -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() { + + 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) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt new file mode 100644 index 0000000000..71101d0612 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingAction.kt @@ -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() +} diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingActivity.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingActivity.kt new file mode 100644 index 0000000000..67b36b8442 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingActivity.kt @@ -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() { + + 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) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt new file mode 100644 index 0000000000..900f465f04 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingFragment.kt @@ -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(), 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" + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt new file mode 100644 index 0000000000..743daaf5e0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewEvents.kt @@ -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() +} diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt new file mode 100644 index 0000000000..b3c97310e1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt @@ -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(initialState) { + + private val room = session.getRoom(initialState.roomId)!! + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: LocationSharingViewState): LocationSharingViewModel + } + + companion object : MavericksViewModelFactory 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) + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt new file mode 100644 index 0000000000..2869929b12 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewState.kt @@ -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 + ) +} diff --git a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt new file mode 100644 index 0000000000..0c0315cf34 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt @@ -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?.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()) + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt new file mode 100644 index 0000000000..c64af1ebaa --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/MapTilerMapView.kt @@ -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}" + } +} diff --git a/vector/src/main/java/im/vector/app/features/location/VectorMapView.kt b/vector/src/main/java/im/vector/app/features/location/VectorMapView.kt new file mode 100644 index 0000000000..23b59bf99a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/location/VectorMapView.kt @@ -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) +} diff --git a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt index b8b663cfcf..f66ced3299 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt @@ -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) diff --git a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt index a6d9268888..775272bd33 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt @@ -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) } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index 611debf339..eb620f8e5c 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -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) + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsPreferencesFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsPreferencesFragment.kt index 2e2fab06a3..50e32ae453 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsPreferencesFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsPreferencesFragment.kt @@ -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(VectorPreferences.SETTINGS_PREF_ENABLE_LOCATION_SHARING)?.isVisible = BuildConfig.enableLocationSharing } private fun updateTakePhotoOrVideoPreferenceSummary() { diff --git a/vector/src/main/res/drawable/bg_map_user_pin.xml b/vector/src/main/res/drawable/bg_map_user_pin.xml new file mode 100644 index 0000000000..148d3cfa29 --- /dev/null +++ b/vector/src/main/res/drawable/bg_map_user_pin.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/drawable/ic_attachment_location_white.xml b/vector/src/main/res/drawable/ic_attachment_location_white.xml new file mode 100644 index 0000000000..865362312b --- /dev/null +++ b/vector/src/main/res/drawable/ic_attachment_location_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_share_external.xml b/vector/src/main/res/drawable/ic_share_external.xml new file mode 100644 index 0000000000..c4b78c8a83 --- /dev/null +++ b/vector/src/main/res/drawable/ic_share_external.xml @@ -0,0 +1,5 @@ + + + diff --git a/vector/src/main/res/layout/activity_location_sharing.xml b/vector/src/main/res/layout/activity_location_sharing.xml new file mode 100755 index 0000000000..bbb46de8c7 --- /dev/null +++ b/vector/src/main/res/layout/activity_location_sharing.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_location_preview.xml b/vector/src/main/res/layout/fragment_location_preview.xml new file mode 100644 index 0000000000..c2b3bdd739 --- /dev/null +++ b/vector/src/main/res/layout/fragment_location_preview.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_location_sharing.xml b/vector/src/main/res/layout/fragment_location_sharing.xml new file mode 100644 index 0000000000..b9f00786de --- /dev/null +++ b/vector/src/main/res/layout/fragment_location_sharing.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_bottom_sheet_message_preview.xml b/vector/src/main/res/layout/item_bottom_sheet_message_preview.xml index 771d4d10f0..95e6975803 100644 --- a/vector/src/main/res/layout/item_bottom_sheet_message_preview.xml +++ b/vector/src/main/res/layout/item_bottom_sheet_message_preview.xml @@ -103,4 +103,18 @@ tools:text="1080 x 1024 - 43s - 12kB" tools:visibility="visible" /> + + diff --git a/vector/src/main/res/layout/item_timeline_event_base.xml b/vector/src/main/res/layout/item_timeline_event_base.xml index 5a04acf677..6360b287d0 100644 --- a/vector/src/main/res/layout/item_timeline_event_base.xml +++ b/vector/src/main/res/layout/item_timeline_event_base.xml @@ -130,6 +130,11 @@ style="@style/TimelineContentStubBaseParams" android:layout="@layout/item_timeline_event_poll" /> + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/view_attachment_type_selector.xml b/vector/src/main/res/layout/view_attachment_type_selector.xml index 77c25572c8..e7a38ab839 100644 --- a/vector/src/main/res/layout/view_attachment_type_selector.xml +++ b/vector/src/main/res/layout/view_attachment_type_selector.xml @@ -72,6 +72,16 @@ android:src="@drawable/ic_attachment_poll" app:tint="?colorPrimary" /> + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 1d25d58027..c4f6abd4ac 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -2454,6 +2454,7 @@ "Gallery" "Sticker" Poll + Location Rotate and crop Couldn\'t handle share data @@ -2769,6 +2770,7 @@ Poll Reacted with: %s Verification Conclusion + Shared their location Waiting… %s cancelled @@ -3721,11 +3723,24 @@ Closed poll Results are only revealed when you end the poll + + Share location + Location + Share location + Share location + ${app_name} could not access your location + ${app_name} could not access your location. Please try again later. + Open with + Enable location sharing + Once enabled you will be able to send your location to any room + Render user locations in the timeline + Open camera Send images and videos Upload file Send sticker Open contacts Create poll + Share location diff --git a/vector/src/main/res/xml/vector_settings_labs.xml b/vector/src/main/res/xml/vector_settings_labs.xml index 7acb1d5016..d5a9a4cd3c 100644 --- a/vector/src/main/res/xml/vector_settings_labs.xml +++ b/vector/src/main/res/xml/vector_settings_labs.xml @@ -57,4 +57,9 @@ android:summary="@string/labs_auto_report_uisi_desc" android:title="@string/labs_auto_report_uisi" /> + + \ No newline at end of file diff --git a/vector/src/main/res/xml/vector_settings_preferences.xml b/vector/src/main/res/xml/vector_settings_preferences.xml index 14c7dc7b80..db0830bca0 100644 --- a/vector/src/main/res/xml/vector_settings_preferences.xml +++ b/vector/src/main/res/xml/vector_settings_preferences.xml @@ -72,6 +72,12 @@ android:title="@string/option_take_photo_video" tools:summary="@string/option_always_ask" /> + +