Merge branch 'develop' into feature/fix_sync_after_call_invite
@ -33,3 +33,8 @@ First of all, we thank all contributors who use Element and report problems on t
|
|||||||
We do not forget all translators, for their work of translating Element into many languages. They are also the authors of Element.
|
We do not forget all translators, for their work of translating Element into many languages. They are also the authors of Element.
|
||||||
|
|
||||||
Feel free to add your name below, when you contribute to the project!
|
Feel free to add your name below, when you contribute to the project!
|
||||||
|
|
||||||
|
Name | Matrix ID | GitHub
|
||||||
|
--------|---------------------|--------------------------------------
|
||||||
|
gjpower | @gjpower:matrix.org | [gjpower](https://github.com/gjpower)
|
||||||
|
|
||||||
|
@ -14,10 +14,16 @@ Improvements 🙌:
|
|||||||
- Room profile: BigImageViewerActivity now only display the image. Use the room setting to change or delete the room Avatar
|
- Room profile: BigImageViewerActivity now only display the image. Use the room setting to change or delete the room Avatar
|
||||||
- Better visibility of text reactions in dark theme (#1118)
|
- Better visibility of text reactions in dark theme (#1118)
|
||||||
- Room member profile: Add action to create (or open) a DM (#2310)
|
- Room member profile: Add action to create (or open) a DM (#2310)
|
||||||
|
- Prepare changelog for F-Droid (#2296)
|
||||||
|
- Add graphic resources for F-Droid (#812, #2220)
|
||||||
|
- Highlight text in the body of the displayed result (#2200)
|
||||||
|
- Considerably faster QR-code bitmap generation (#2331)
|
||||||
|
|
||||||
Bugfix 🐛:
|
Bugfix 🐛:
|
||||||
|
- Fixed ringtone handling (#2100 & #2246)
|
||||||
- Messages encrypted with no way to decrypt after SDK update from 0.18 to 1.0.0 (#2252)
|
- Messages encrypted with no way to decrypt after SDK update from 0.18 to 1.0.0 (#2252)
|
||||||
- Incoming call continues to ring if call is answered on another device (#1921)
|
- Incoming call continues to ring if call is answered on another device (#1921)
|
||||||
|
- Search Result | scroll jumps after pagination (#2238)
|
||||||
|
|
||||||
Translations 🗣:
|
Translations 🗣:
|
||||||
-
|
-
|
||||||
|
1
fastlane/metadata/android/en-US/changelogs/40100100.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
// TODO
|
BIN
fastlane/metadata/android/en-US/images/featureGraphic.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
Normal file
After Width: | Height: | Size: 131 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
Normal file
After Width: | Height: | Size: 316 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
Normal file
After Width: | Height: | Size: 310 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
Normal file
After Width: | Height: | Size: 543 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
Normal file
After Width: | Height: | Size: 341 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/6.png
Normal file
After Width: | Height: | Size: 334 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/7.png
Normal file
After Width: | Height: | Size: 551 KiB |
@ -167,11 +167,14 @@ internal class DefaultTimeline(
|
|||||||
val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst()
|
val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst()
|
||||||
?: throw IllegalStateException("Can't open a timeline without a room")
|
?: throw IllegalStateException("Can't open a timeline without a room")
|
||||||
|
|
||||||
sendingEvents = roomEntity.sendingTimelineEvents.where().filterEventsWithSettings().findAll()
|
// We don't want to filter here because some sending events that are not displayed
|
||||||
|
// are still used for ui echo (relation like reaction)
|
||||||
|
sendingEvents = roomEntity.sendingTimelineEvents.where()/*.filterEventsWithSettings()*/.findAll()
|
||||||
sendingEvents.addChangeListener { events ->
|
sendingEvents.addChangeListener { events ->
|
||||||
uiEchoManager.sentEventsUpdated(events)
|
uiEchoManager.sentEventsUpdated(events)
|
||||||
postSnapshot()
|
postSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
nonFilteredEvents = buildEventQuery(realm).sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING).findAll()
|
nonFilteredEvents = buildEventQuery(realm).sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING).findAll()
|
||||||
filteredEvents = nonFilteredEvents.where()
|
filteredEvents = nonFilteredEvents.where()
|
||||||
.filterEventsWithSettings()
|
.filterEventsWithSettings()
|
||||||
@ -410,11 +413,14 @@ internal class DefaultTimeline(
|
|||||||
val builtSendingEvents = ArrayList<TimelineEvent>()
|
val builtSendingEvents = ArrayList<TimelineEvent>()
|
||||||
if (hasReachedEnd(Timeline.Direction.FORWARDS) && !hasMoreInCache(Timeline.Direction.FORWARDS)) {
|
if (hasReachedEnd(Timeline.Direction.FORWARDS) && !hasMoreInCache(Timeline.Direction.FORWARDS)) {
|
||||||
builtSendingEvents.addAll(uiEchoManager.getInMemorySendingEvents().filterEventsWithSettings())
|
builtSendingEvents.addAll(uiEchoManager.getInMemorySendingEvents().filterEventsWithSettings())
|
||||||
sendingEvents.forEach { timelineEventEntity ->
|
sendingEvents
|
||||||
if (builtSendingEvents.find { it.eventId == timelineEventEntity.eventId } == null) {
|
.map { timelineEventMapper.map(it) }
|
||||||
val element = timelineEventMapper.map(timelineEventEntity)
|
// Filter out sending event that are not displayable!
|
||||||
uiEchoManager.updateSentStateWithUiEcho(element)
|
.filterEventsWithSettings()
|
||||||
builtSendingEvents.add(element)
|
.forEach { timelineEvent ->
|
||||||
|
if (builtSendingEvents.find { it.eventId == timelineEvent.eventId } == null) {
|
||||||
|
uiEchoManager.updateSentStateWithUiEcho(timelineEvent)
|
||||||
|
builtSendingEvents.add(timelineEvent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -782,8 +788,8 @@ internal class DefaultTimeline(
|
|||||||
val filterType = !settings.filters.filterTypes || settings.filters.allowedTypes.contains(it.root.type)
|
val filterType = !settings.filters.filterTypes || settings.filters.allowedTypes.contains(it.root.type)
|
||||||
if (!filterType) return@filter false
|
if (!filterType) return@filter false
|
||||||
|
|
||||||
val filterEdits = if (settings.filters.filterEdits && it.root.type == EventType.MESSAGE) {
|
val filterEdits = if (settings.filters.filterEdits && it.root.getClearType() == EventType.MESSAGE) {
|
||||||
val messageContent = it.root.content.toModel<MessageContent>()
|
val messageContent = it.root.getClearContent().toModel<MessageContent>()
|
||||||
messageContent?.relatesTo?.type != RelationType.REPLACE && messageContent?.relatesTo?.type != RelationType.RESPONSE
|
messageContent?.relatesTo?.type != RelationType.REPLACE && messageContent?.relatesTo?.type != RelationType.RESPONSE
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
@ -813,7 +819,7 @@ internal class DefaultTimeline(
|
|||||||
private val inMemorySendingEvents = Collections.synchronizedList<TimelineEvent>(ArrayList())
|
private val inMemorySendingEvents = Collections.synchronizedList<TimelineEvent>(ArrayList())
|
||||||
|
|
||||||
fun getInMemorySendingEvents(): List<TimelineEvent> {
|
fun getInMemorySendingEvents(): List<TimelineEvent> {
|
||||||
return inMemorySendingEvents
|
return inMemorySendingEvents.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -831,8 +837,13 @@ internal class DefaultTimeline(
|
|||||||
inMemorySendingStates.keys.removeAll { key ->
|
inMemorySendingStates.keys.removeAll { key ->
|
||||||
events.find { it.eventId == key } == null
|
events.find { it.eventId == key } == null
|
||||||
}
|
}
|
||||||
inMemoryReactions.keys.removeAll { key ->
|
|
||||||
events.find { it.eventId == key } == null
|
inMemoryReactions.forEach { (_, uiEchoData) ->
|
||||||
|
uiEchoData.removeAll { data ->
|
||||||
|
// I remove the uiEcho, when the related event is not anymore in the sending list
|
||||||
|
// (means that it is synced)!
|
||||||
|
events.find { it.eventId == data.localEchoId } == null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -896,6 +907,7 @@ internal class DefaultTimeline(
|
|||||||
relatedEventID
|
relatedEventID
|
||||||
)
|
)
|
||||||
val updateReactions = existingAnnotationSummary.reactionsSummary.toMutableList()
|
val updateReactions = existingAnnotationSummary.reactionsSummary.toMutableList()
|
||||||
|
|
||||||
contents.forEach { uiEchoReaction ->
|
contents.forEach { uiEchoReaction ->
|
||||||
val existing = updateReactions.firstOrNull { it.key == uiEchoReaction.reaction }
|
val existing = updateReactions.firstOrNull { it.key == uiEchoReaction.reaction }
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
|
@ -34,12 +34,15 @@ fun String.toBitMatrix(size: Int): BitMatrix {
|
|||||||
|
|
||||||
fun BitMatrix.toBitmap(@ColorInt backgroundColor: Int = Color.WHITE,
|
fun BitMatrix.toBitmap(@ColorInt backgroundColor: Int = Color.WHITE,
|
||||||
@ColorInt foregroundColor: Int = Color.BLACK): Bitmap {
|
@ColorInt foregroundColor: Int = Color.BLACK): Bitmap {
|
||||||
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
val colorBuffer = IntArray(width * height)
|
||||||
for (x in 0 until width) {
|
var rowOffset = 0
|
||||||
for (y in 0 until height) {
|
for (y in 0 until height) {
|
||||||
bmp.setPixel(x, y, if (get(x, y)) foregroundColor else backgroundColor)
|
for (x in 0 until width) {
|
||||||
|
val arrayIndex = x + rowOffset
|
||||||
|
colorBuffer[arrayIndex] = if (get(x, y)) foregroundColor else backgroundColor
|
||||||
}
|
}
|
||||||
|
rowOffset += width
|
||||||
}
|
}
|
||||||
|
|
||||||
return bmp
|
return Bitmap.createBitmap(colorBuffer, width, height, Bitmap.Config.ARGB_8888)
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,8 @@
|
|||||||
package im.vector.app.core.services
|
package im.vector.app.core.services
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.Ringtone
|
||||||
|
import android.media.RingtoneManager
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.media.MediaPlayer
|
import android.media.MediaPlayer
|
||||||
@ -25,7 +27,26 @@ import androidx.core.content.getSystemService
|
|||||||
import im.vector.app.R
|
import im.vector.app.R
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
class CallRingPlayer(
|
class CallRingPlayerIncoming(
|
||||||
|
context: Context
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val applicationContext = context.applicationContext
|
||||||
|
private var r: Ringtone? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
val notification = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
|
||||||
|
r = RingtoneManager.getRingtone(applicationContext, notification)
|
||||||
|
Timber.v("## VOIP Starting ringing incomming")
|
||||||
|
r?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
r?.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CallRingPlayerOutgoing(
|
||||||
context: Context
|
context: Context
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@ -44,12 +65,12 @@ class CallRingPlayer(
|
|||||||
try {
|
try {
|
||||||
if (player?.isPlaying == false) {
|
if (player?.isPlaying == false) {
|
||||||
player?.start()
|
player?.start()
|
||||||
Timber.v("## VOIP Starting ringing")
|
Timber.v("## VOIP Starting ringing outgoing")
|
||||||
} else {
|
} else {
|
||||||
Timber.v("## VOIP already playing")
|
Timber.v("## VOIP already playing")
|
||||||
}
|
}
|
||||||
} catch (failure: Throwable) {
|
} catch (failure: Throwable) {
|
||||||
Timber.e(failure, "## VOIP Failed to start ringing")
|
Timber.e(failure, "## VOIP Failed to start ringing outgoing")
|
||||||
player = null
|
player = null
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -74,7 +95,7 @@ class CallRingPlayer(
|
|||||||
} else {
|
} else {
|
||||||
mediaPlayer.setAudioAttributes(AudioAttributes.Builder()
|
mediaPlayer.setAudioAttributes(AudioAttributes.Builder()
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING)
|
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||||
.build())
|
.build())
|
||||||
}
|
}
|
||||||
return mediaPlayer
|
return mediaPlayer
|
||||||
|
@ -40,7 +40,8 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
|
|||||||
private lateinit var notificationUtils: NotificationUtils
|
private lateinit var notificationUtils: NotificationUtils
|
||||||
private lateinit var webRtcPeerConnectionManager: WebRtcPeerConnectionManager
|
private lateinit var webRtcPeerConnectionManager: WebRtcPeerConnectionManager
|
||||||
|
|
||||||
private var callRingPlayer: CallRingPlayer? = null
|
private var callRingPlayerIncoming: CallRingPlayerIncoming? = null
|
||||||
|
private var callRingPlayerOutgoing: CallRingPlayerOutgoing? = null
|
||||||
|
|
||||||
private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null
|
private var wiredHeadsetStateReceiver: WiredHeadsetStateReceiver? = null
|
||||||
private var bluetoothHeadsetStateReceiver: BluetoothHeadsetReceiver? = null
|
private var bluetoothHeadsetStateReceiver: BluetoothHeadsetReceiver? = null
|
||||||
@ -63,14 +64,16 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
notificationUtils = vectorComponent().notificationUtils()
|
notificationUtils = vectorComponent().notificationUtils()
|
||||||
webRtcPeerConnectionManager = vectorComponent().webRtcPeerConnectionManager()
|
webRtcPeerConnectionManager = vectorComponent().webRtcPeerConnectionManager()
|
||||||
callRingPlayer = CallRingPlayer(applicationContext)
|
callRingPlayerIncoming = CallRingPlayerIncoming(applicationContext)
|
||||||
|
callRingPlayerOutgoing = CallRingPlayerOutgoing(applicationContext)
|
||||||
wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(this, this)
|
wiredHeadsetStateReceiver = WiredHeadsetStateReceiver.createAndRegister(this, this)
|
||||||
bluetoothHeadsetStateReceiver = BluetoothHeadsetReceiver.createAndRegister(this, this)
|
bluetoothHeadsetStateReceiver = BluetoothHeadsetReceiver.createAndRegister(this, this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
callRingPlayer?.stop()
|
callRingPlayerIncoming?.stop()
|
||||||
|
callRingPlayerOutgoing?.stop()
|
||||||
wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(this, it) }
|
wiredHeadsetStateReceiver?.let { WiredHeadsetStateReceiver.unRegister(this, it) }
|
||||||
wiredHeadsetStateReceiver = null
|
wiredHeadsetStateReceiver = null
|
||||||
bluetoothHeadsetStateReceiver?.let { BluetoothHeadsetReceiver.unRegister(this, it) }
|
bluetoothHeadsetStateReceiver?.let { BluetoothHeadsetReceiver.unRegister(this, it) }
|
||||||
@ -100,16 +103,17 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
|
|||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
ACTION_INCOMING_RINGING_CALL -> {
|
ACTION_INCOMING_RINGING_CALL -> {
|
||||||
mediaSession?.isActive = true
|
mediaSession?.isActive = true
|
||||||
callRingPlayer?.start()
|
callRingPlayerIncoming?.start()
|
||||||
displayIncomingCallNotification(intent)
|
displayIncomingCallNotification(intent)
|
||||||
}
|
}
|
||||||
ACTION_OUTGOING_RINGING_CALL -> {
|
ACTION_OUTGOING_RINGING_CALL -> {
|
||||||
mediaSession?.isActive = true
|
mediaSession?.isActive = true
|
||||||
callRingPlayer?.start()
|
callRingPlayerOutgoing?.start()
|
||||||
displayOutgoingRingingCallNotification(intent)
|
displayOutgoingRingingCallNotification(intent)
|
||||||
}
|
}
|
||||||
ACTION_ONGOING_CALL -> {
|
ACTION_ONGOING_CALL -> {
|
||||||
callRingPlayer?.stop()
|
callRingPlayerIncoming?.stop()
|
||||||
|
callRingPlayerOutgoing?.stop()
|
||||||
displayCallInProgressNotification(intent)
|
displayCallInProgressNotification(intent)
|
||||||
}
|
}
|
||||||
ACTION_NO_ACTIVE_CALL -> hideCallNotifications()
|
ACTION_NO_ACTIVE_CALL -> hideCallNotifications()
|
||||||
@ -117,7 +121,8 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
|
|||||||
// lower notification priority
|
// lower notification priority
|
||||||
displayCallInProgressNotification(intent)
|
displayCallInProgressNotification(intent)
|
||||||
// stop ringing
|
// stop ringing
|
||||||
callRingPlayer?.stop()
|
callRingPlayerIncoming?.stop()
|
||||||
|
callRingPlayerOutgoing?.stop()
|
||||||
}
|
}
|
||||||
ACTION_ONGOING_CALL_BG -> {
|
ACTION_ONGOING_CALL_BG -> {
|
||||||
// there is an ongoing call but call activity is in background
|
// there is an ongoing call but call activity is in background
|
||||||
@ -125,7 +130,8 @@ class CallService : VectorService(), WiredHeadsetStateReceiver.HeadsetEventListe
|
|||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Should not happen
|
// Should not happen
|
||||||
callRingPlayer?.stop()
|
callRingPlayerIncoming?.stop()
|
||||||
|
callRingPlayerOutgoing?.stop()
|
||||||
myStopSelf()
|
myStopSelf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -93,6 +93,10 @@ class CallAudioManager(
|
|||||||
|
|
||||||
fun startForCall(mxCall: MxCall) {
|
fun startForCall(mxCall: MxCall) {
|
||||||
Timber.v("## VOIP: AudioManager startForCall ${mxCall.callId}")
|
Timber.v("## VOIP: AudioManager startForCall ${mxCall.callId}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupAudioManager(mxCall: MxCall) {
|
||||||
|
Timber.v("## VOIP: AudioManager setupAudioManager ${mxCall.callId}")
|
||||||
val audioManager = audioManager ?: return
|
val audioManager = audioManager ?: return
|
||||||
savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn
|
savedIsSpeakerPhoneOn = audioManager.isSpeakerphoneOn
|
||||||
savedIsMicrophoneMute = audioManager.isMicrophoneMute
|
savedIsMicrophoneMute = audioManager.isMicrophoneMute
|
||||||
@ -150,7 +154,7 @@ class CallAudioManager(
|
|||||||
|
|
||||||
fun onCallConnected(mxCall: MxCall) {
|
fun onCallConnected(mxCall: MxCall) {
|
||||||
Timber.v("##VOIP: AudioManager call answered, adjusting current sound device")
|
Timber.v("##VOIP: AudioManager call answered, adjusting current sound device")
|
||||||
adjustCurrentSoundDevice(mxCall)
|
setupAudioManager(mxCall)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAvailableSoundDevices(): List<SoundDevice> {
|
fun getAvailableSoundDevices(): List<SoundDevice> {
|
||||||
|
@ -52,8 +52,6 @@ class SearchFragment @Inject constructor(
|
|||||||
private val fragmentArgs: SearchArgs by args()
|
private val fragmentArgs: SearchArgs by args()
|
||||||
private val searchViewModel: SearchViewModel by fragmentViewModel()
|
private val searchViewModel: SearchViewModel by fragmentViewModel()
|
||||||
|
|
||||||
private var pendingScrollToPosition: Int? = null
|
|
||||||
|
|
||||||
override fun getLayoutResId() = R.layout.fragment_search
|
override fun getLayoutResId() = R.layout.fragment_search
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
@ -70,12 +68,6 @@ class SearchFragment @Inject constructor(
|
|||||||
searchResultRecycler.configureWith(controller, showDivider = false)
|
searchResultRecycler.configureWith(controller, showDivider = false)
|
||||||
(searchResultRecycler.layoutManager as? LinearLayoutManager)?.stackFromEnd = true
|
(searchResultRecycler.layoutManager as? LinearLayoutManager)?.stackFromEnd = true
|
||||||
controller.listener = this
|
controller.listener = this
|
||||||
|
|
||||||
controller.addModelBuildListener {
|
|
||||||
pendingScrollToPosition?.let {
|
|
||||||
searchResultRecycler.smoothScrollToPosition(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
@ -100,10 +92,8 @@ class SearchFragment @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pendingScrollToPosition = (state.lastBatchSize - 1).coerceAtLeast(0)
|
|
||||||
|
|
||||||
stateView.state = StateView.State.Content
|
|
||||||
controller.setData(state)
|
controller.setData(state)
|
||||||
|
stateView.state = StateView.State.Content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,16 +16,24 @@
|
|||||||
|
|
||||||
package im.vector.app.features.home.room.detail.search
|
package im.vector.app.features.home.room.detail.search
|
||||||
|
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.text.Spannable
|
||||||
|
import android.text.SpannableString
|
||||||
|
import android.text.style.StyleSpan
|
||||||
|
import com.airbnb.epoxy.EpoxyModel
|
||||||
import com.airbnb.epoxy.TypedEpoxyController
|
import com.airbnb.epoxy.TypedEpoxyController
|
||||||
import com.airbnb.epoxy.VisibilityState
|
import com.airbnb.epoxy.VisibilityState
|
||||||
|
import im.vector.app.R
|
||||||
import im.vector.app.core.date.DateFormatKind
|
import im.vector.app.core.date.DateFormatKind
|
||||||
import im.vector.app.core.date.VectorDateFormatter
|
import im.vector.app.core.date.VectorDateFormatter
|
||||||
import im.vector.app.core.epoxy.loadingItem
|
import im.vector.app.core.epoxy.loadingItem
|
||||||
import im.vector.app.core.ui.list.genericItemHeader
|
import im.vector.app.core.epoxy.noResultItem
|
||||||
|
import im.vector.app.core.resources.StringProvider
|
||||||
|
import im.vector.app.core.ui.list.GenericItemHeader_
|
||||||
import im.vector.app.features.home.AvatarRenderer
|
import im.vector.app.features.home.AvatarRenderer
|
||||||
import org.matrix.android.sdk.api.session.Session
|
import org.matrix.android.sdk.api.session.Session
|
||||||
|
import org.matrix.android.sdk.api.session.events.model.Content
|
||||||
import org.matrix.android.sdk.api.session.events.model.Event
|
import org.matrix.android.sdk.api.session.events.model.Event
|
||||||
import org.matrix.android.sdk.api.session.search.EventAndSender
|
|
||||||
import org.matrix.android.sdk.api.util.toMatrixItem
|
import org.matrix.android.sdk.api.util.toMatrixItem
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@ -33,6 +41,7 @@ import javax.inject.Inject
|
|||||||
class SearchResultController @Inject constructor(
|
class SearchResultController @Inject constructor(
|
||||||
private val session: Session,
|
private val session: Session,
|
||||||
private val avatarRenderer: AvatarRenderer,
|
private val avatarRenderer: AvatarRenderer,
|
||||||
|
private val stringProvider: StringProvider,
|
||||||
private val dateFormatter: VectorDateFormatter
|
private val dateFormatter: VectorDateFormatter
|
||||||
) : TypedEpoxyController<SearchViewState>() {
|
) : TypedEpoxyController<SearchViewState>() {
|
||||||
|
|
||||||
@ -52,6 +61,8 @@ class SearchResultController @Inject constructor(
|
|||||||
override fun buildModels(data: SearchViewState?) {
|
override fun buildModels(data: SearchViewState?) {
|
||||||
data ?: return
|
data ?: return
|
||||||
|
|
||||||
|
val searchItems = buildSearchResultItems(data)
|
||||||
|
|
||||||
if (data.hasMoreResult) {
|
if (data.hasMoreResult) {
|
||||||
loadingItem {
|
loadingItem {
|
||||||
// Always use a different id, because we can be notified several times of visibility state changed
|
// Always use a different id, because we can be notified several times of visibility state changed
|
||||||
@ -62,35 +73,85 @@ class SearchResultController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (searchItems.isEmpty()) {
|
||||||
|
// All returned results by the server has been filtered out and there is no more result
|
||||||
|
noResultItem {
|
||||||
|
id("noResult")
|
||||||
|
text(stringProvider.getString(R.string.no_result_placeholder))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
noResultItem {
|
||||||
|
id("noMoreResult")
|
||||||
|
text(stringProvider.getString(R.string.no_more_results))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildSearchResultItems(data.searchResult)
|
searchItems.forEach { add(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSearchResultItems(events: List<EventAndSender>) {
|
/**
|
||||||
|
* @return the list of EpoxyModel (date items and search result items), or an empty list if all items have been filtered out
|
||||||
|
*/
|
||||||
|
private fun buildSearchResultItems(data: SearchViewState): List<EpoxyModel<*>> {
|
||||||
var lastDate: Calendar? = null
|
var lastDate: Calendar? = null
|
||||||
|
val result = mutableListOf<EpoxyModel<*>>()
|
||||||
|
|
||||||
|
data.searchResult.forEach { eventAndSender ->
|
||||||
|
val event = eventAndSender.event
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
// Take new content first
|
||||||
|
val text = ((event.content?.get("m.new_content") as? Content) ?: event.content)?.get("body") as? String ?: return@forEach
|
||||||
|
val spannable = setHighLightedText(text, data.highlights) ?: return@forEach
|
||||||
|
|
||||||
events.forEach { eventAndSender ->
|
|
||||||
val eventDate = Calendar.getInstance().apply {
|
val eventDate = Calendar.getInstance().apply {
|
||||||
timeInMillis = eventAndSender.event.originServerTs ?: System.currentTimeMillis()
|
timeInMillis = eventAndSender.event.originServerTs ?: System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
if (lastDate?.get(Calendar.DAY_OF_YEAR) != eventDate.get(Calendar.DAY_OF_YEAR)) {
|
if (lastDate?.get(Calendar.DAY_OF_YEAR) != eventDate.get(Calendar.DAY_OF_YEAR)) {
|
||||||
genericItemHeader {
|
GenericItemHeader_()
|
||||||
id(eventDate.hashCode())
|
.id(eventDate.hashCode())
|
||||||
text(dateFormatter.format(eventDate.timeInMillis, DateFormatKind.EDIT_HISTORY_HEADER))
|
.text(dateFormatter.format(eventDate.timeInMillis, DateFormatKind.EDIT_HISTORY_HEADER))
|
||||||
}
|
.let { result.add(it) }
|
||||||
}
|
}
|
||||||
lastDate = eventDate
|
lastDate = eventDate
|
||||||
|
|
||||||
searchResultItem {
|
SearchResultItem_()
|
||||||
id(eventAndSender.event.eventId)
|
.id(eventAndSender.event.eventId)
|
||||||
avatarRenderer(avatarRenderer)
|
.avatarRenderer(avatarRenderer)
|
||||||
dateFormatter(dateFormatter)
|
.formattedDate(dateFormatter.format(event.originServerTs, DateFormatKind.MESSAGE_SIMPLE))
|
||||||
event(eventAndSender.event)
|
.spannable(spannable)
|
||||||
sender(eventAndSender.sender
|
.sender(eventAndSender.sender
|
||||||
?: eventAndSender.event.senderId?.let { session.getUser(it) }?.toMatrixItem())
|
?: eventAndSender.event.senderId?.let { session.getUser(it) }?.toMatrixItem())
|
||||||
listener { listener?.onItemClicked(eventAndSender.event) }
|
.listener { listener?.onItemClicked(eventAndSender.event) }
|
||||||
|
.let { result.add(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight the text. If the text is not found, return null to ignore this result
|
||||||
|
* See https://github.com/matrix-org/synapse/issues/8686
|
||||||
|
*/
|
||||||
|
private fun setHighLightedText(text: String, highlights: List<String>): Spannable? {
|
||||||
|
val wordToSpan: Spannable = SpannableString(text)
|
||||||
|
var found = false
|
||||||
|
highlights.forEach { highlight ->
|
||||||
|
var searchFromIndex = 0
|
||||||
|
while (searchFromIndex < text.length) {
|
||||||
|
val indexOfHighlight = text.indexOf(highlight, searchFromIndex, ignoreCase = true)
|
||||||
|
searchFromIndex = if (indexOfHighlight == -1) {
|
||||||
|
Integer.MAX_VALUE
|
||||||
|
} else {
|
||||||
|
// bold
|
||||||
|
found = true
|
||||||
|
wordToSpan.setSpan(StyleSpan(Typeface.BOLD), indexOfHighlight, indexOfHighlight + highlight.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
indexOfHighlight + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return wordToSpan.takeIf { found }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,23 +21,20 @@ import android.widget.TextView
|
|||||||
import com.airbnb.epoxy.EpoxyAttribute
|
import com.airbnb.epoxy.EpoxyAttribute
|
||||||
import com.airbnb.epoxy.EpoxyModelClass
|
import com.airbnb.epoxy.EpoxyModelClass
|
||||||
import im.vector.app.R
|
import im.vector.app.R
|
||||||
import im.vector.app.core.date.DateFormatKind
|
|
||||||
import im.vector.app.core.date.VectorDateFormatter
|
|
||||||
import im.vector.app.core.epoxy.ClickListener
|
import im.vector.app.core.epoxy.ClickListener
|
||||||
import im.vector.app.core.epoxy.VectorEpoxyHolder
|
import im.vector.app.core.epoxy.VectorEpoxyHolder
|
||||||
import im.vector.app.core.epoxy.VectorEpoxyModel
|
import im.vector.app.core.epoxy.VectorEpoxyModel
|
||||||
import im.vector.app.core.epoxy.onClick
|
import im.vector.app.core.epoxy.onClick
|
||||||
import im.vector.app.core.extensions.setTextOrHide
|
import im.vector.app.core.extensions.setTextOrHide
|
||||||
import im.vector.app.features.home.AvatarRenderer
|
import im.vector.app.features.home.AvatarRenderer
|
||||||
import org.matrix.android.sdk.api.session.events.model.Event
|
|
||||||
import org.matrix.android.sdk.api.util.MatrixItem
|
import org.matrix.android.sdk.api.util.MatrixItem
|
||||||
|
|
||||||
@EpoxyModelClass(layout = R.layout.item_search_result)
|
@EpoxyModelClass(layout = R.layout.item_search_result)
|
||||||
abstract class SearchResultItem : VectorEpoxyModel<SearchResultItem.Holder>() {
|
abstract class SearchResultItem : VectorEpoxyModel<SearchResultItem.Holder>() {
|
||||||
|
|
||||||
@EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
|
@EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
|
||||||
@EpoxyAttribute var dateFormatter: VectorDateFormatter? = null
|
@EpoxyAttribute var formattedDate: String? = null
|
||||||
@EpoxyAttribute lateinit var event: Event
|
@EpoxyAttribute lateinit var spannable: CharSequence
|
||||||
@EpoxyAttribute var sender: MatrixItem? = null
|
@EpoxyAttribute var sender: MatrixItem? = null
|
||||||
@EpoxyAttribute var listener: ClickListener? = null
|
@EpoxyAttribute var listener: ClickListener? = null
|
||||||
|
|
||||||
@ -47,9 +44,8 @@ abstract class SearchResultItem : VectorEpoxyModel<SearchResultItem.Holder>() {
|
|||||||
holder.view.onClick(listener)
|
holder.view.onClick(listener)
|
||||||
sender?.let { avatarRenderer.render(it, holder.avatarImageView) }
|
sender?.let { avatarRenderer.render(it, holder.avatarImageView) }
|
||||||
holder.memberNameView.setTextOrHide(sender?.getBestName())
|
holder.memberNameView.setTextOrHide(sender?.getBestName())
|
||||||
holder.timeView.text = dateFormatter?.format(event.originServerTs, DateFormatKind.MESSAGE_SIMPLE)
|
holder.timeView.text = formattedDate
|
||||||
// TODO Improve that (use formattedBody, etc.)
|
holder.contentView.text = spannable
|
||||||
holder.contentView.text = event.content?.get("body") as? String
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Holder : VectorEpoxyHolder() {
|
class Holder : VectorEpoxyHolder() {
|
||||||
|
@ -145,6 +145,7 @@ class SearchViewModel @AssistedInject constructor(
|
|||||||
setState {
|
setState {
|
||||||
copy(
|
copy(
|
||||||
searchResult = accumulatedResult,
|
searchResult = accumulatedResult,
|
||||||
|
highlights = searchResult.highlights.orEmpty(),
|
||||||
hasMoreResult = !nextBatch.isNullOrEmpty(),
|
hasMoreResult = !nextBatch.isNullOrEmpty(),
|
||||||
lastBatchSize = searchResult.results.orEmpty().size,
|
lastBatchSize = searchResult.results.orEmpty().size,
|
||||||
asyncSearchRequest = Success(Unit)
|
asyncSearchRequest = Success(Unit)
|
||||||
|
@ -24,6 +24,7 @@ import org.matrix.android.sdk.api.session.search.EventAndSender
|
|||||||
data class SearchViewState(
|
data class SearchViewState(
|
||||||
// Accumulated search result
|
// Accumulated search result
|
||||||
val searchResult: List<EventAndSender> = emptyList(),
|
val searchResult: List<EventAndSender> = emptyList(),
|
||||||
|
val highlights: List<String> = emptyList(),
|
||||||
val hasMoreResult: Boolean = false,
|
val hasMoreResult: Boolean = false,
|
||||||
// Last batch size, will help RecyclerView to position itself
|
// Last batch size, will help RecyclerView to position itself
|
||||||
val lastBatchSize: Int = 0,
|
val lastBatchSize: Int = 0,
|
||||||
|
@ -8,8 +8,10 @@
|
|||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/searchResultRecycler"
|
android:id="@+id/searchResultRecycler"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
android:overScrollMode="always"
|
android:overScrollMode="always"
|
||||||
|
tools:itemCount="2"
|
||||||
tools:listitem="@layout/item_search_result" />
|
tools:listitem="@layout/item_search_result" />
|
||||||
|
|
||||||
</im.vector.app.core.platform.StateView>
|
</im.vector.app.core.platform.StateView>
|
@ -173,6 +173,7 @@
|
|||||||
<string name="no_conversation_placeholder">No conversations</string>
|
<string name="no_conversation_placeholder">No conversations</string>
|
||||||
<string name="no_contact_access_placeholder">You didn’t allow Element to access your local contacts</string>
|
<string name="no_contact_access_placeholder">You didn’t allow Element to access your local contacts</string>
|
||||||
<string name="no_result_placeholder">No results</string>
|
<string name="no_result_placeholder">No results</string>
|
||||||
|
<string name="no_more_results">No more results</string>
|
||||||
<string name="people_no_identity_server">No identity server configured.</string>
|
<string name="people_no_identity_server">No identity server configured.</string>
|
||||||
|
|
||||||
<!-- Rooms fragment -->
|
<!-- Rooms fragment -->
|
||||||
|