Merge pull request #1493 from simophin/ses-1936-oom

[SES-1936] Fix memory leaks
pull/1502/head
Andrew 1 month ago committed by GitHub
commit b757691334
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -50,7 +50,7 @@ public class AttachmentServer implements Runnable {
throws IOException throws IOException
{ {
try { try {
this.context = context; this.context = context.getApplicationContext();
this.attachment = attachment; this.attachment = attachment;
this.socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1})); this.socket = new ServerSocket(0, 0, InetAddress.getByAddress(new byte[]{127, 0, 0, 1}));
this.port = socket.getLocalPort(); this.port = socket.getLocalPort();

@ -122,7 +122,7 @@ class ProfilePictureView @JvmOverloads constructor(
glide.clear(imageView) glide.clear(imageView)
val placeholder = PlaceholderAvatarPhoto(context, publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}") val placeholder = PlaceholderAvatarPhoto(publicKey, displayName ?: "${publicKey.take(4)}...${publicKey.takeLast(4)}")
if (signalProfilePicture != null && avatar != "0" && avatar != "") { if (signalProfilePicture != null && avatar != "0" && avatar != "") {
glide.load(signalProfilePicture) glide.load(signalProfilePicture)

@ -287,8 +287,10 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
if (hexEncodedSeed == null) { if (hexEncodedSeed == null) {
hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(this).hexEncodedPrivateKey // Legacy account hexEncodedSeed = IdentityKeyUtil.getIdentityKeyPair(this).hexEncodedPrivateKey // Legacy account
} }
val appContext = applicationContext
val loadFileContents: (String) -> String = { fileName -> val loadFileContents: (String) -> String = { fileName ->
MnemonicUtilities.loadFileContents(this, fileName) MnemonicUtilities.loadFileContents(appContext, fileName)
} }
MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english) MnemonicCodec(loadFileContents).encode(hexEncodedSeed!!, MnemonicCodec.Language.Configuration.english)
} }
@ -831,6 +833,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
override fun onDestroy() { override fun onDestroy() {
viewModel.saveDraft(binding?.inputBar?.text?.trim() ?: "") viewModel.saveDraft(binding?.inputBar?.text?.trim() ?: "")
cancelVoiceMessage()
tearDownRecipientObserver() tearDownRecipientObserver()
super.onDestroy() super.onDestroy()
binding = null binding = null
@ -1019,7 +1022,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
} }
override fun showVoiceMessageUI() { override fun showVoiceMessageUI() {
binding?.inputBarRecordingView?.show() binding?.inputBarRecordingView?.show(lifecycleScope)
binding?.inputBar?.alpha = 0.0f binding?.inputBar?.alpha = 0.0f
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
animation.duration = 250L animation.duration = 250L

@ -4,8 +4,6 @@ import android.animation.FloatEvaluator
import android.animation.IntEvaluator import android.animation.IntEvaluator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.content.Context import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.ImageView import android.widget.ImageView
@ -14,6 +12,11 @@ import android.widget.RelativeLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ViewInputBarRecordingBinding import network.loki.messenger.databinding.ViewInputBarRecordingBinding
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
@ -25,10 +28,10 @@ import java.util.Date
class InputBarRecordingView : RelativeLayout { class InputBarRecordingView : RelativeLayout {
private lateinit var binding: ViewInputBarRecordingBinding private lateinit var binding: ViewInputBarRecordingBinding
private var startTimestamp = 0L private var startTimestamp = 0L
private val snHandler = Handler(Looper.getMainLooper())
private var dotViewAnimation: ValueAnimator? = null private var dotViewAnimation: ValueAnimator? = null
private var pulseAnimation: ValueAnimator? = null private var pulseAnimation: ValueAnimator? = null
var delegate: InputBarRecordingViewDelegate? = null var delegate: InputBarRecordingViewDelegate? = null
private var timerJob: Job? = null
val lockView: LinearLayout val lockView: LinearLayout
get() = binding.lockView get() = binding.lockView
@ -50,9 +53,10 @@ class InputBarRecordingView : RelativeLayout {
binding = ViewInputBarRecordingBinding.inflate(LayoutInflater.from(context), this, true) binding = ViewInputBarRecordingBinding.inflate(LayoutInflater.from(context), this, true)
binding.inputBarMiddleContentContainer.disableClipping() binding.inputBarMiddleContentContainer.disableClipping()
binding.inputBarCancelButton.setOnClickListener { hide() } binding.inputBarCancelButton.setOnClickListener { hide() }
} }
fun show() { fun show(scope: CoroutineScope) {
startTimestamp = Date().time startTimestamp = Date().time
binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_microphone, context.theme)) binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_microphone, context.theme))
binding.inputBarCancelButton.alpha = 0.0f binding.inputBarCancelButton.alpha = 0.0f
@ -69,7 +73,7 @@ class InputBarRecordingView : RelativeLayout {
animateDotView() animateDotView()
pulse() pulse()
animateLockViewUp() animateLockViewUp()
updateTimer() startTimer(scope)
} }
fun hide() { fun hide() {
@ -86,6 +90,24 @@ class InputBarRecordingView : RelativeLayout {
} }
animation.start() animation.start()
delegate?.handleVoiceMessageUIHidden() delegate?.handleVoiceMessageUIHidden()
stopTimer()
}
private fun startTimer(scope: CoroutineScope) {
timerJob?.cancel()
timerJob = scope.launch {
while (isActive) {
val duration = (Date().time - startTimestamp) / 1000L
binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration)
delay(500)
}
}
}
private fun stopTimer() {
timerJob?.cancel()
timerJob = null
} }
private fun animateDotView() { private fun animateDotView() {
@ -129,12 +151,6 @@ class InputBarRecordingView : RelativeLayout {
animation.start() animation.start()
} }
private fun updateTimer() {
val duration = (Date().time - startTimestamp) / 1000L
binding.recordingViewDurationTextView.text = DateUtils.formatElapsedTime(duration)
snHandler.postDelayed({ updateTimer() }, 500)
}
fun lock() { fun lock() {
val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f) val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
fadeOutAnimation.duration = 250L fadeOutAnimation.duration = 250L

@ -881,6 +881,10 @@ public class ThreadDatabase extends Database {
this.cursor = cursor; this.cursor = cursor;
} }
public int getCount() {
return cursor == null ? 0 : cursor.getCount();
}
public ThreadRecord getNext() { public ThreadRecord getNext() {
if (cursor == null || !cursor.moveToNext()) if (cursor == null || !cursor.moveToNext())
return null; return null;

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.glide package org.thoughtcrime.securesms.glide
import android.content.Context
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import com.bumptech.glide.load.Options import com.bumptech.glide.load.Options
import com.bumptech.glide.load.model.ModelLoader import com.bumptech.glide.load.model.ModelLoader
@ -8,7 +9,7 @@ import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory import com.bumptech.glide.load.model.MultiModelLoaderFactory
import org.session.libsession.avatars.PlaceholderAvatarPhoto import org.session.libsession.avatars.PlaceholderAvatarPhoto
class PlaceholderAvatarLoader(): ModelLoader<PlaceholderAvatarPhoto, BitmapDrawable> { class PlaceholderAvatarLoader(private val appContext: Context): ModelLoader<PlaceholderAvatarPhoto, BitmapDrawable> {
override fun buildLoadData( override fun buildLoadData(
model: PlaceholderAvatarPhoto, model: PlaceholderAvatarPhoto,
@ -16,14 +17,14 @@ class PlaceholderAvatarLoader(): ModelLoader<PlaceholderAvatarPhoto, BitmapDrawa
height: Int, height: Int,
options: Options options: Options
): LoadData<BitmapDrawable> { ): LoadData<BitmapDrawable> {
return LoadData(model, PlaceholderAvatarFetcher(model.context, model)) return LoadData(model, PlaceholderAvatarFetcher(appContext, model))
} }
override fun handles(model: PlaceholderAvatarPhoto): Boolean = true override fun handles(model: PlaceholderAvatarPhoto): Boolean = true
class Factory() : ModelLoaderFactory<PlaceholderAvatarPhoto, BitmapDrawable> { class Factory(private val appContext: Context) : ModelLoaderFactory<PlaceholderAvatarPhoto, BitmapDrawable> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<PlaceholderAvatarPhoto, BitmapDrawable> { override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<PlaceholderAvatarPhoto, BitmapDrawable> {
return PlaceholderAvatarLoader() return PlaceholderAvatarLoader(appContext)
} }
override fun teardown() {} override fun teardown() {}
} }

@ -2,12 +2,9 @@ package org.thoughtcrime.securesms.home
import android.Manifest import android.Manifest
import android.app.NotificationManager import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.SpannableString import android.text.SpannableString
@ -18,13 +15,14 @@ import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -81,7 +79,6 @@ import org.thoughtcrime.securesms.util.IP2Country
import org.thoughtcrime.securesms.util.disableClipping import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.push import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show import org.thoughtcrime.securesms.util.show
import org.thoughtcrime.securesms.util.themeState
import java.io.IOException import java.io.IOException
import java.util.Locale import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@ -99,7 +96,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private lateinit var binding: ActivityHomeBinding private lateinit var binding: ActivityHomeBinding
private lateinit var glide: GlideRequests private lateinit var glide: GlideRequests
private var broadcastReceiver: BroadcastReceiver? = null
@Inject lateinit var threadDb: ThreadDatabase @Inject lateinit var threadDb: ThreadDatabase
@Inject lateinit var mmsSmsDatabase: MmsSmsDatabase @Inject lateinit var mmsSmsDatabase: MmsSmsDatabase
@ -205,18 +201,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// Set up empty state view // Set up empty state view
binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() } binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() }
IP2Country.configureIfNeeded(this@HomeActivity) IP2Country.configureIfNeeded(this@HomeActivity)
startObservingUpdates()
// Set up new conversation button // Set up new conversation button
binding.newConversationButton.setOnClickListener { showNewConversation() } binding.newConversationButton.setOnClickListener { showNewConversation() }
// Observe blocked contacts changed events // Observe blocked contacts changed events
val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
binding.recyclerView.adapter!!.notifyDataSetChanged()
}
}
this.broadcastReceiver = broadcastReceiver
LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, IntentFilter("blockedContactsChanged"))
// subscribe to outdated config updates, this should be removed after long enough time for device migration // subscribe to outdated config updates, this should be removed after long enough time for device migration
lifecycleScope.launch { lifecycleScope.launch {
@ -227,6 +215,27 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
} }
} }
// Subscribe to threads and update the UI
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
homeViewModel.threads
.filterNotNull() // We don't actually want the null value here as it indicates a loading state (maybe we need a loading state?)
.collectLatest { threads ->
val manager = binding.recyclerView.layoutManager as LinearLayoutManager
val firstPos = manager.findFirstCompletelyVisibleItemPosition()
val offsetTop = if(firstPos >= 0) {
manager.findViewByPosition(firstPos)?.let { view ->
manager.getDecoratedTop(view) - manager.getTopDecorationHeight(view)
} ?: 0
} else 0
homeAdapter.data = threads
if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) }
setupMessageRequestsBanner()
updateEmptyState()
}
}
}
lifecycleScope.launchWhenStarted { lifecycleScope.launchWhenStarted {
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
// Double check that the long poller is up // Double check that the long poller is up
@ -385,52 +394,20 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity) ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity)
} }
} }
// If the theme hasn't changed then start observing updates again (if it does change then we
// will recreate the activity resulting in it responding to changes multiple times)
if (currentThemeState == textSecurePreferences.themeState() && !homeViewModel.getObservable(this).hasActiveObservers()) {
startObservingUpdates()
}
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(false) ApplicationContext.getInstance(this).messageNotifier.setHomeScreenVisible(false)
homeViewModel.getObservable(this).removeObservers(this)
} }
override fun onDestroy() { override fun onDestroy() {
val broadcastReceiver = this.broadcastReceiver
if (broadcastReceiver != null) {
LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver)
}
super.onDestroy() super.onDestroy()
EventBus.getDefault().unregister(this) EventBus.getDefault().unregister(this)
} }
// endregion // endregion
// region Updating // region Updating
private fun startObservingUpdates() {
homeViewModel.getObservable(this).observe(this) { newData ->
val manager = binding.recyclerView.layoutManager as LinearLayoutManager
val firstPos = manager.findFirstCompletelyVisibleItemPosition()
val offsetTop = if(firstPos >= 0) {
manager.findViewByPosition(firstPos)?.let { view ->
manager.getDecoratedTop(view) - manager.getTopDecorationHeight(view)
} ?: 0
} else 0
homeAdapter.data = newData
if(firstPos >= 0) { manager.scrollToPositionWithOffset(firstPos, offsetTop) }
setupMessageRequestsBanner()
updateEmptyState()
}
ApplicationContext.getInstance(this@HomeActivity).typingStatusRepository.typingThreads.observe(this) { threadIds ->
homeAdapter.typingThreadIDs = (threadIds ?: setOf())
}
}
private fun updateEmptyState() { private fun updateEmptyState() {
val threadCount = (binding.recyclerView.adapter)!!.itemCount val threadCount = (binding.recyclerView.adapter)!!.itemCount
binding.emptyStateContainer.isVisible = threadCount == 0 && binding.recyclerView.isVisible binding.emptyStateContainer.isVisible = threadCount == 0 && binding.recyclerView.isVisible
@ -441,7 +418,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
if (event.recipient.isLocalNumber) { if (event.recipient.isLocalNumber) {
updateProfileButton() updateProfileButton()
} else { } else {
homeViewModel.tryUpdateChannel() homeViewModel.tryReload()
} }
} }
@ -612,7 +589,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private fun setConversationPinned(threadId: Long, pinned: Boolean) { private fun setConversationPinned(threadId: Long, pinned: Boolean) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
storage.setPinned(threadId, pinned) storage.setPinned(threadId, pinned)
homeViewModel.tryUpdateChannel() homeViewModel.tryReload()
} }
} }
@ -688,7 +665,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
button(R.string.yes) { button(R.string.yes) {
textSecurePreferences.setHasHiddenMessageRequests() textSecurePreferences.setHasHiddenMessageRequests()
setupMessageRequestsBanner() setupMessageRequestsBanner()
homeViewModel.tryUpdateChannel() homeViewModel.tryReload()
} }
button(R.string.no) button(R.string.no)
} }

@ -9,7 +9,6 @@ import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_ID import androidx.recyclerview.widget.RecyclerView.NO_ID
import network.loki.messenger.R import network.loki.messenger.R
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.GlideRequests
@ -26,14 +25,15 @@ class HomeAdapter(
var header: View? = null var header: View? = null
private var _data: List<ThreadRecord> = emptyList() var data: HomeViewModel.Data = HomeViewModel.Data(emptyList(), emptySet())
var data: List<ThreadRecord>
get() = _data.toList()
set(newData) { set(newData) {
val previousData = _data.toList() if (field === newData) {
val diff = HomeDiffUtil(previousData, newData, context, configFactory) return
}
val diff = HomeDiffUtil(field, newData, context, configFactory)
val diffResult = DiffUtil.calculateDiff(diff) val diffResult = DiffUtil.calculateDiff(diff)
_data = newData field = newData
diffResult.dispatchUpdatesTo(this as ListUpdateCallback) diffResult.dispatchUpdatesTo(this as ListUpdateCallback)
} }
@ -61,18 +61,10 @@ class HomeAdapter(
override fun getItemId(position: Int): Long { override fun getItemId(position: Int): Long {
if (hasHeaderView() && position == 0) return NO_ID if (hasHeaderView() && position == 0) return NO_ID
val offsetPosition = if (hasHeaderView()) position-1 else position val offsetPosition = if (hasHeaderView()) position-1 else position
return _data[offsetPosition].threadId return data.threads[offsetPosition].threadId
} }
lateinit var glide: GlideRequests lateinit var glide: GlideRequests
var typingThreadIDs = setOf<Long>()
set(value) {
if (field == value) { return }
field = value
// TODO: replace this with a diffed update or a partial change set with payloads
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
when (viewType) { when (viewType) {
@ -95,8 +87,8 @@ class HomeAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (holder is ConversationViewHolder) { if (holder is ConversationViewHolder) {
val offset = if (hasHeaderView()) position - 1 else position val offset = if (hasHeaderView()) position - 1 else position
val thread = data[offset] val thread = data.threads[offset]
val isTyping = typingThreadIDs.contains(thread.threadId) val isTyping = data.typingThreadIDs.contains(thread.threadId)
holder.view.bind(thread, isTyping, glide) holder.view.bind(thread, isTyping, glide)
} }
} }
@ -113,7 +105,7 @@ class HomeAdapter(
if (hasHeaderView() && position == 0) HEADER if (hasHeaderView() && position == 0) HEADER
else ITEM else ITEM
override fun getItemCount(): Int = data.size + if (hasHeaderView()) 1 else 0 override fun getItemCount(): Int = data.threads.size + if (hasHeaderView()) 1 else 0
class ConversationViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view) class ConversationViewHolder(val view: ConversationView) : RecyclerView.ViewHolder(view)

@ -2,27 +2,26 @@ package org.thoughtcrime.securesms.home
import android.content.Context import android.content.Context
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.getConversationUnread import org.thoughtcrime.securesms.util.getConversationUnread
class HomeDiffUtil( class HomeDiffUtil(
private val old: List<ThreadRecord>, private val old: HomeViewModel.Data,
private val new: List<ThreadRecord>, private val new: HomeViewModel.Data,
private val context: Context, private val context: Context,
private val configFactory: ConfigFactory private val configFactory: ConfigFactory
): DiffUtil.Callback() { ): DiffUtil.Callback() {
override fun getOldListSize(): Int = old.size override fun getOldListSize(): Int = old.threads.size
override fun getNewListSize(): Int = new.size override fun getNewListSize(): Int = new.threads.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean = override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
old[oldItemPosition].threadId == new[newItemPosition].threadId old.threads[oldItemPosition].threadId == new.threads[newItemPosition].threadId
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = old[oldItemPosition] val oldItem = old.threads[oldItemPosition]
val newItem = new[newItemPosition] val newItem = new.threads[newItemPosition]
// return early to save getDisplayBody or expensive calls // return early to save getDisplayBody or expensive calls
var isSameItem = true var isSameItem = true
@ -47,7 +46,8 @@ class HomeDiffUtil(
oldItem.isSent == newItem.isSent && oldItem.isSent == newItem.isSent &&
oldItem.isPending == newItem.isPending && oldItem.isPending == newItem.isPending &&
oldItem.lastSeen == newItem.lastSeen && oldItem.lastSeen == newItem.lastSeen &&
configFactory.convoVolatile?.getConversationUnread(newItem) != true configFactory.convoVolatile?.getConversationUnread(newItem) != true &&
old.typingThreadIDs.contains(oldItem.threadId) == new.typingThreadIDs.contains(newItem.threadId)
) )
} }

@ -1,71 +1,88 @@
package org.thoughtcrime.securesms.home package org.thoughtcrime.securesms.home
import android.content.ContentResolver
import android.content.Context import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.cash.copper.flow.observeQuery
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.database.DatabaseContentProviders import org.thoughtcrime.securesms.database.DatabaseContentProviders
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.database.model.ThreadRecord
import java.lang.ref.WeakReference import org.thoughtcrime.securesms.util.observeChanges
import javax.inject.Inject import javax.inject.Inject
import dagger.hilt.android.qualifiers.ApplicationContext as ApplicationContextQualifier
@HiltViewModel @HiltViewModel
class HomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): ViewModel() { class HomeViewModel @Inject constructor(
private val threadDb: ThreadDatabase,
private val contentResolver: ContentResolver,
@ApplicationContextQualifier private val context: Context,
) : ViewModel() {
// SharedFlow that emits whenever the user asks us to reload the conversation
private val manualReloadTrigger = MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private val executor = viewModelScope + SupervisorJob() /**
private var lastContext: WeakReference<Context>? = null * A [StateFlow] that emits the list of threads and the typing status of each thread.
private var updateJobs: MutableList<Job> = mutableListOf() *
* This flow will emit whenever the user asks us to reload the conversation list or
* whenever the conversation list changes.
*/
val threads: StateFlow<Data?> = combine(observeConversationList(), observeTypingStatus(), ::Data)
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val _conversations = MutableLiveData<List<ThreadRecord>>() private fun observeTypingStatus(): Flow<Set<Long>> =
val conversations: LiveData<List<ThreadRecord>> = _conversations ApplicationContext.getInstance(context).typingStatusRepository
.typingThreads
.asFlow()
.onStart { emit(emptySet()) }
.distinctUntilChanged()
private val listUpdateChannel = Channel<Unit>(capacity = Channel.CONFLATED) @Suppress("OPT_IN_USAGE")
private fun observeConversationList(): Flow<List<ThreadRecord>> = merge(
fun tryUpdateChannel() = listUpdateChannel.trySend(Unit) manualReloadTrigger,
contentResolver.observeChanges(DatabaseContentProviders.ConversationList.CONTENT_URI))
fun getObservable(context: Context): LiveData<List<ThreadRecord>> { .debounce(CHANGE_NOTIFICATION_DEBOUNCE_MILLS)
// If the context has changed (eg. the activity gets recreated) then .onStart { emit(Unit) }
// we need to cancel the old executors and recreate them to prevent .mapLatest { _ ->
// the app from triggering extra updates when data changes withContext(Dispatchers.IO) {
if (context != lastContext?.get()) { threadDb.approvedConversationList.use { openCursor ->
lastContext = WeakReference(context) val reader = threadDb.readerFor(openCursor)
updateJobs.forEach { it.cancel() } buildList(reader.count) {
updateJobs.clear()
updateJobs.add(
executor.launch(Dispatchers.IO) {
context.contentResolver
.observeQuery(DatabaseContentProviders.ConversationList.CONTENT_URI)
.onEach { listUpdateChannel.trySend(Unit) }
.collect()
}
)
updateJobs.add(
executor.launch(Dispatchers.IO) {
for (update in listUpdateChannel) {
threadDb.approvedConversationList.use { openCursor ->
val reader = threadDb.readerFor(openCursor)
val threads = mutableListOf<ThreadRecord>()
while (true) { while (true) {
threads += reader.next ?: break add(reader.next ?: break)
}
withContext(Dispatchers.Main) {
_conversations.value = threads
} }
} }
} }
} }
) }
}
return conversations fun tryReload() = manualReloadTrigger.tryEmit(Unit)
}
} data class Data(
val threads: List<ThreadRecord>,
val typingThreadIDs: Set<Long>
)
companion object {
private const val CHANGE_NOTIFICATION_DEBOUNCE_MILLS = 100L
}
}

@ -6,7 +6,6 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.util.AttributeSet import android.util.AttributeSet
import android.util.TypedValue import android.util.TypedValue
import android.view.Gravity import android.view.Gravity
@ -17,11 +16,17 @@ import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityPathBinding import network.loki.messenger.databinding.ActivityPathBinding
import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.utilities.getColorFromAttr import org.session.libsession.utilities.getColorFromAttr
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.Snode
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.util.GlowViewUtilities import org.thoughtcrime.securesms.util.GlowViewUtilities
@ -184,6 +189,7 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
private lateinit var location: Location private lateinit var location: Location
private var dotAnimationStartDelay: Long = 0 private var dotAnimationStartDelay: Long = 0
private var dotAnimationRepeatInterval: Long = 0 private var dotAnimationRepeatInterval: Long = 0
private var job: Job? = null
private val dotView by lazy { private val dotView by lazy {
val result = PathDotView(context) val result = PathDotView(context)
@ -240,19 +246,38 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
dotViewLayoutParams.addRule(CENTER_IN_PARENT) dotViewLayoutParams.addRule(CENTER_IN_PARENT)
dotView.layoutParams = dotViewLayoutParams dotView.layoutParams = dotViewLayoutParams
addView(dotView) addView(dotView)
Handler().postDelayed({
performAnimation()
}, dotAnimationStartDelay)
} }
private fun performAnimation() { override fun onAttachedToWindow() {
expand() super.onAttachedToWindow()
Handler().postDelayed({
collapse() startAnimation()
Handler().postDelayed({ }
performAnimation()
}, dotAnimationRepeatInterval) override fun onDetachedFromWindow() {
}, 1000) super.onDetachedFromWindow()
stopAnimation()
}
private fun startAnimation() {
job?.cancel()
job = GlobalScope.launch {
withContext(Dispatchers.Main) {
while (isActive) {
delay(dotAnimationStartDelay)
expand()
delay(EXPAND_ANIM_DELAY_MILLS)
collapse()
delay(dotAnimationRepeatInterval)
}
}
}
}
private fun stopAnimation() {
job?.cancel()
job = null
} }
private fun expand() { private fun expand() {
@ -270,6 +295,10 @@ class PathActivity : PassphraseRequiredActionBarActivity() {
val endColor = context.resources.getColorWithID(endColorID, context.theme) val endColor = context.resources.getColorWithID(endColorID, context.theme)
GlowViewUtilities.animateShadowColorChange(dotView, startColor, endColor) GlowViewUtilities.animateShadowColorChange(dotView, startColor, endColor)
} }
companion object {
private const val EXPAND_ANIM_DELAY_MILLS = 1000L
}
} }
// endregion // endregion
} }

@ -73,7 +73,7 @@ public class SignalGlideModule extends AppGlideModule {
registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context)); registry.append(DecryptableUri.class, InputStream.class, new DecryptableStreamUriLoader.Factory(context));
registry.append(AttachmentModel.class, InputStream.class, new AttachmentStreamUriLoader.Factory()); registry.append(AttachmentModel.class, InputStream.class, new AttachmentStreamUriLoader.Factory());
registry.append(ChunkedImageUrl.class, InputStream.class, new ChunkedImageUrlLoader.Factory()); registry.append(ChunkedImageUrl.class, InputStream.class, new ChunkedImageUrlLoader.Factory());
registry.append(PlaceholderAvatarPhoto.class, BitmapDrawable.class, new PlaceholderAvatarLoader.Factory()); registry.append(PlaceholderAvatarPhoto.class, BitmapDrawable.class, new PlaceholderAvatarLoader.Factory(context));
registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory()); registry.replace(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory());
} }

@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.util
import android.content.ContentResolver
import android.database.ContentObserver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import androidx.annotation.CheckResult
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
/**
* Observe changes to a content Uri. This function will emit the Uri whenever the content or
* its descendants change, according to the parameter [notifyForDescendants].
*/
@CheckResult
fun ContentResolver.observeChanges(uri: Uri, notifyForDescendants: Boolean = false): Flow<Uri> {
return callbackFlow {
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) {
trySend(uri)
}
}
registerContentObserver(uri, notifyForDescendants, observer)
awaitClose {
unregisterContentObserver(observer)
}
}
}

@ -55,7 +55,7 @@ class IP2Country private constructor(private val context: Context) {
public fun configureIfNeeded(context: Context) { public fun configureIfNeeded(context: Context) {
if (isInitialized) { return; } if (isInitialized) { return; }
shared = IP2Country(context) shared = IP2Country(context.applicationContext)
} }
} }

@ -1,13 +1,10 @@
package org.session.libsession.avatars package org.session.libsession.avatars
import android.content.Context
import com.bumptech.glide.load.Key import com.bumptech.glide.load.Key
import java.security.MessageDigest import java.security.MessageDigest
class PlaceholderAvatarPhoto(val context: Context, class PlaceholderAvatarPhoto(val hashString: String,
val hashString: String,
val displayName: String): Key { val displayName: String): Key {
override fun updateDiskCacheKey(messageDigest: MessageDigest) { override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update(hashString.encodeToByteArray()) messageDigest.update(hashString.encodeToByteArray())
messageDigest.update(displayName.encodeToByteArray()) messageDigest.update(displayName.encodeToByteArray())

@ -70,7 +70,7 @@ public class Recipient implements RecipientModifiedListener {
private final @NonNull Address address; private final @NonNull Address address;
private final @NonNull List<Recipient> participants = new LinkedList<>(); private final @NonNull List<Recipient> participants = new LinkedList<>();
private Context context; private final Context context;
private @Nullable String name; private @Nullable String name;
private @Nullable String customLabel; private @Nullable String customLabel;
private boolean resolving; private boolean resolving;
@ -132,7 +132,7 @@ public class Recipient implements RecipientModifiedListener {
@NonNull Optional<RecipientDetails> details, @NonNull Optional<RecipientDetails> details,
@NonNull ListenableFutureTask<RecipientDetails> future) @NonNull ListenableFutureTask<RecipientDetails> future)
{ {
this.context = context; this.context = context.getApplicationContext();
this.address = address; this.address = address;
this.color = null; this.color = null;
this.resolving = true; this.resolving = true;
@ -259,7 +259,7 @@ public class Recipient implements RecipientModifiedListener {
} }
Recipient(@NonNull Context context, @NonNull Address address, @NonNull RecipientDetails details) { Recipient(@NonNull Context context, @NonNull Address address, @NonNull RecipientDetails details) {
this.context = context; this.context = context.getApplicationContext();
this.address = address; this.address = address;
this.contactUri = details.contactUri; this.contactUri = details.contactUri;
this.name = details.name; this.name = details.name;

Loading…
Cancel
Save