From 829c1959ee81991b9a17588c6685b63a8d3cd8af Mon Sep 17 00:00:00 2001 From: Son of Binary Date: Fri, 10 Jul 2026 11:01:25 +0100 Subject: [PATCH 1/3] Optimized threads conversation view, by caching frequently computed data. Optimized UX by preventing blocked conversations from showing up in the default inbox. --- .../data/dao/ThreadsDao.kt | 2 +- .../data/entities/Threads.kt | 2 + .../ui/ThreadsConversationMain.kt | 135 +++++++++++++----- 3 files changed, 102 insertions(+), 37 deletions(-) diff --git a/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/data/dao/ThreadsDao.kt b/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/data/dao/ThreadsDao.kt index 51efab5..91b60cd 100644 --- a/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/data/dao/ThreadsDao.kt +++ b/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/data/dao/ThreadsDao.kt @@ -11,7 +11,7 @@ import com.afkanerd.smswithoutborders_libsmsmms.data.entities.Threads @Dao interface ThreadsDao { - @Query("SELECT * FROM Threads WHERE isArchive = 0 AND address IS NOT NULL ORDER BY isPinned DESC, date DESC") + @Query("SELECT * FROM Threads WHERE isArchive = 0 AND isBlocked = 0 AND address IS NOT NULL ORDER BY isPinned DESC, date DESC") fun getThreads0(): PagingSource @Query("SELECT * FROM Threads WHERE isArchive = 0 AND address IS NOT NULL AND isPinned = 1 ORDER BY date DESC, isPinned DESC") diff --git a/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/data/entities/Threads.kt b/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/data/entities/Threads.kt index 024a48a..ffc0917 100644 --- a/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/data/entities/Threads.kt +++ b/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/data/entities/Threads.kt @@ -1,11 +1,13 @@ package com.afkanerd.smswithoutborders_libsmsmms.data.entities import android.provider.Telephony +import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey +@Immutable @Entity(indices = [Index(value = ["address"], unique = true)]) data class Threads( @PrimaryKey var threadId: Int, diff --git a/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/ThreadsConversationMain.kt b/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/ThreadsConversationMain.kt index edef8d3..78b35a4 100644 --- a/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/ThreadsConversationMain.kt +++ b/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/ThreadsConversationMain.kt @@ -1,7 +1,8 @@ package com.afkanerd.smswithoutborders_libsmsmms.ui +import android.annotation.SuppressLint +import android.content.Context import android.os.Debug -import android.provider.BlockedNumberContract.isBlocked import android.provider.Telephony import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable @@ -33,12 +34,10 @@ import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.NotificationsOff -import androidx.compose.material.icons.filled.PianoOff import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Unarchive import androidx.compose.material.icons.outlined.Block -import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material.icons.outlined.Remove import androidx.compose.material.icons.rounded.Delete import androidx.compose.material3.CenterAlignedTopAppBar @@ -57,6 +56,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -86,11 +86,9 @@ import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController import androidx.paging.LoadState.Loading import androidx.paging.PagingData -import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey -import coil3.Uri -import coil3.toUri +import androidx.paging.map import com.afkanerd.lib_smsmms_android.R import com.afkanerd.smswithoutborders_libsmsmms.data.data.models.DateTimeUtils import com.afkanerd.smswithoutborders_libsmsmms.data.entities.Threads @@ -114,14 +112,17 @@ import com.afkanerd.smswithoutborders_libsmsmms.ui.viewModels.ThreadsViewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlin.collections.map import kotlin.math.roundToInt data class ThreadsConversationParameters( var searchQuery: String? = null, ) +@SuppressLint("FlowOperatorInvokedInComposition") @OptIn( ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class, ExperimentalFoundationApi::class, ExperimentalPermissionsApi::class @@ -151,7 +152,7 @@ fun ThreadConversationLayout( val messagesAreLoading = threadsViewModel.messagesLoading val secondaryMessagesAreLoading = threadsViewModel.secondaryMessagesLoading - var inboxType by remember { mutableStateOf(ThreadsViewModel.InboxType.INBOX )} + var inboxType by remember { mutableStateOf(ThreadsViewModel.InboxType.INBOX) } val isAndroidJUnitTest = try { Class.forName("androidx.test.runner.AndroidJUnitRunner") true @@ -173,11 +174,15 @@ fun ThreadConversationLayout( val selectedItems by threadsViewModel.selectedItems.collectAsState() - val inboxMessagesPagers = threadsViewModel.getThreads(context) - val archivedMessagesPagers = threadsViewModel.getArchives(context) + + val inboxMessagesPagers = + threadsViewModel.getThreads(context) + val archivedMessagesPagers = + threadsViewModel.getArchives(context) val draftMessagesPagers = threadsViewModel.getDrafts(context) val mutedMessagesPagers = threadsViewModel.getIsMute(context) - val blockedMessagesPager = threadsViewModel.getIsBlocked(context) + val blockedMessagesPager = + threadsViewModel.getIsBlocked(context) val inboxMessagesItems = inboxMessagesPagers.collectAsLazyPagingItems() val archivedMessagesItems = archivedMessagesPagers.collectAsLazyPagingItems() @@ -204,6 +209,9 @@ fun ThreadConversationLayout( val scope = rememberCoroutineScope() + + var cachedExtras: HashMap = HashMap() + BackHandler( inboxType != ThreadsViewModel.InboxType.INBOX || !selectedItems.isEmpty() @@ -563,12 +571,13 @@ fun ThreadConversationLayout( if (!isDefault || !readPhoneStatePermission.status.isGranted) { DefaultCheckMain { isDefault = context.isDefault() } } - if(secondaryMessagesAreLoading || isAndroidJUnitTest || inPreviewMode) + if (secondaryMessagesAreLoading || isAndroidJUnitTest || inPreviewMode) LinearProgressIndicator( - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .testTag("secondaryMessagesAreLoading") ) - if(messagesAreLoading || inPreviewMode) { + if (messagesAreLoading || inPreviewMode) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, @@ -629,6 +638,8 @@ fun ThreadConversationLayout( ) { index -> val thread = displayedInbox[index] ?: return@items + val extra = cachedExtras.getItem(thread, threadsViewModel, context) + val offsetX = remember { Animatable(0f) } val threshold = 300f @@ -700,24 +711,6 @@ fun ThreadConversationLayout( ) { val address = thread.address - val isBlocked = remember(thread.threadId) { - if (isDefault) - threadsViewModel.isBlocked( - context, thread, - blockedMessagesItems.itemSnapshotList.items - ) - else false - } - - val contactName = remember(address) { - if (isDefault) - context.retrieveContactName(address) - else address - } - - val contactPhotoUri by threadsViewModel - .contactPhoto(context, address) - .collectAsState() val isSelected = remember(selectedItems) { selectedItems.contains(thread) @@ -732,12 +725,12 @@ fun ThreadConversationLayout( ThreadConversationCard( id = thread.threadId, - name = contactName ?: address, + name = extra.contactName ?: thread.address, content = thread.snippet, date = date, isRead = !thread.unread, - isContact = isDefault && !contactName.isNullOrBlank(), - isBlocked = isBlocked, + isContact = extra.isContact, + isBlocked = inboxType == ThreadsViewModel.InboxType.BLOCKED, isPinned = thread.isPinned, modifier = Modifier.combinedClickable( onClick = { @@ -784,7 +777,7 @@ fun ThreadConversationLayout( type = thread.type, unreadCount = thread.unreadCount, mms = thread.isMms, - contactPhotoUri = contactPhotoUri, + contactPhotoUri = extra.contactPhotoUri, ) } } @@ -816,6 +809,76 @@ fun ThreadConversationLayout( } +/** + * This class provides access to the same fields that a Threads object has, + * while providing useful additional fields. + * The additional fields are mostly about values that are lazily computed and cached. + */ +@Immutable +data class ThreadsExtended( + val raw: Threads, + private val threadsViewModel: ThreadsViewModel, + private val context: Context +) { + + val contactName by lazy { + // Now, why? query the system to fetch a contact for a sender id, when it's technically impossible + // to store a contact whose "number" is a text-based sender id? + if (!this.canBeContact) { + raw.address + } else { + context.retrieveContactName(raw.address) + } + } + + val contactPhotoUri by lazy { + // Now, why search for contact photo for an address that is not saveable as a contact? + // Where should the contact photo come from? + if (!this.canBeContact) return@lazy null + + threadsViewModel + .contactPhoto(context, raw.address).value + } + + val isContact by lazy { + if (!this.canBeContact) { + false + } else { + !contactName.isNullOrBlank() + } + } + + /** + * This field tells us if the address is saveable as a contact. + * This helps us reduce unnecessary computation in other areas. + */ + val canBeContact by lazy { + // The address can be a contact, if it starts with a numeric value + // When checking, let's not check the entire sequence. Let's further cut costs, by + // checking only the first character. + Regex("^[0-9+]$").matches(raw.address.first().toString()) + } + +} + + +/** + * This function maps a flow of paging data, such that it contains + * necessary information that is frequently computed. + * In this way, we reduce repetitive work that was previously done on the UI. + */ +fun HashMap.getItem( + thread: Threads, + threadsViewModel: ThreadsViewModel, + context: Context, +): ThreadsExtended { + // We don't need to always keep re-constructing this object. + // We need a way to cache this within the current context + return this.getOrPut(thread.address, { + ThreadsExtended(thread, threadsViewModel, context) + }) +} + @Preview @Composable fun PreviewMessageCard() { From f9cc6173e9e163166b91e06e3e79aaa325e6a686 Mon Sep 17 00:00:00 2001 From: Son of Binary Date: Fri, 10 Jul 2026 11:45:24 +0100 Subject: [PATCH 2/3] Removed unnecessary old code. --- .../smswithoutborders_libsmsmms/ui/ThreadsConversationMain.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/ThreadsConversationMain.kt b/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/ThreadsConversationMain.kt index 78b35a4..a14a992 100644 --- a/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/ThreadsConversationMain.kt +++ b/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/ThreadsConversationMain.kt @@ -122,7 +122,6 @@ data class ThreadsConversationParameters( var searchQuery: String? = null, ) -@SuppressLint("FlowOperatorInvokedInComposition") @OptIn( ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class, ExperimentalFoundationApi::class, ExperimentalPermissionsApi::class From c24d35d2aefef752392e9437b013b249a1c8d7e4 Mon Sep 17 00:00:00 2001 From: Son of Binary Date: Fri, 10 Jul 2026 18:21:53 +0100 Subject: [PATCH 3/3] Improved memory efficiency. --- .../ui/ThreadsConversationMain.kt | 88 +--------- .../ui/viewModels/ThreadsViewModel.kt | 164 +++++++++++++----- 2 files changed, 128 insertions(+), 124 deletions(-) diff --git a/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/ThreadsConversationMain.kt b/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/ThreadsConversationMain.kt index a14a992..5845dcf 100644 --- a/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/ThreadsConversationMain.kt +++ b/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/ThreadsConversationMain.kt @@ -1,6 +1,5 @@ package com.afkanerd.smswithoutborders_libsmsmms.ui -import android.annotation.SuppressLint import android.content.Context import android.os.Debug import android.provider.Telephony @@ -85,10 +84,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController import androidx.paging.LoadState.Loading -import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey -import androidx.paging.map import com.afkanerd.lib_smsmms_android.R import com.afkanerd.smswithoutborders_libsmsmms.data.data.models.DateTimeUtils import com.afkanerd.smswithoutborders_libsmsmms.data.entities.Threads @@ -112,8 +109,6 @@ import com.afkanerd.smswithoutborders_libsmsmms.ui.viewModels.ThreadsViewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlin.collections.map import kotlin.math.roundToInt @@ -209,7 +204,6 @@ fun ThreadConversationLayout( val scope = rememberCoroutineScope() - var cachedExtras: HashMap = HashMap() BackHandler( inboxType != ThreadsViewModel.InboxType.INBOX || @@ -633,11 +627,10 @@ fun ThreadConversationLayout( ) { items( count = displayedInbox.itemCount, - key = displayedInbox.itemKey { it.threadId } + key = displayedInbox.itemKey { it.raw.threadId } ) { index -> - val thread = displayedInbox[index] ?: return@items - - val extra = cachedExtras.getItem(thread, threadsViewModel, context) + val enriched = displayedInbox[index] ?: return@items + val thread = enriched.raw; val offsetX = remember { Animatable(0f) } val threshold = 300f @@ -724,11 +717,11 @@ fun ThreadConversationLayout( ThreadConversationCard( id = thread.threadId, - name = extra.contactName ?: thread.address, + name = enriched.contactName ?: thread.address, content = thread.snippet, date = date, isRead = !thread.unread, - isContact = extra.isContact, + isContact = enriched.isContact, isBlocked = inboxType == ThreadsViewModel.InboxType.BLOCKED, isPinned = thread.isPinned, modifier = Modifier.combinedClickable( @@ -776,7 +769,7 @@ fun ThreadConversationLayout( type = thread.type, unreadCount = thread.unreadCount, mms = thread.isMms, - contactPhotoUri = extra.contactPhotoUri, + contactPhotoUri = enriched.contactPhotoUri, ) } } @@ -808,75 +801,6 @@ fun ThreadConversationLayout( } -/** - * This class provides access to the same fields that a Threads object has, - * while providing useful additional fields. - * The additional fields are mostly about values that are lazily computed and cached. - */ -@Immutable -data class ThreadsExtended( - val raw: Threads, - private val threadsViewModel: ThreadsViewModel, - private val context: Context -) { - - val contactName by lazy { - // Now, why? query the system to fetch a contact for a sender id, when it's technically impossible - // to store a contact whose "number" is a text-based sender id? - if (!this.canBeContact) { - raw.address - } else { - context.retrieveContactName(raw.address) - } - } - - val contactPhotoUri by lazy { - // Now, why search for contact photo for an address that is not saveable as a contact? - // Where should the contact photo come from? - if (!this.canBeContact) return@lazy null - - threadsViewModel - .contactPhoto(context, raw.address).value - } - - val isContact by lazy { - if (!this.canBeContact) { - false - } else { - !contactName.isNullOrBlank() - } - } - - /** - * This field tells us if the address is saveable as a contact. - * This helps us reduce unnecessary computation in other areas. - */ - val canBeContact by lazy { - // The address can be a contact, if it starts with a numeric value - // When checking, let's not check the entire sequence. Let's further cut costs, by - // checking only the first character. - Regex("^[0-9+]$").matches(raw.address.first().toString()) - } - -} - - -/** - * This function maps a flow of paging data, such that it contains - * necessary information that is frequently computed. - * In this way, we reduce repetitive work that was previously done on the UI. - */ -fun HashMap.getItem( - thread: Threads, - threadsViewModel: ThreadsViewModel, - context: Context, -): ThreadsExtended { - // We don't need to always keep re-constructing this object. - // We need a way to cache this within the current context - return this.getOrPut(thread.address, { - ThreadsExtended(thread, threadsViewModel, context) - }) -} @Preview @Composable diff --git a/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/viewModels/ThreadsViewModel.kt b/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/viewModels/ThreadsViewModel.kt index 8c3c2cf..4dc18c2 100644 --- a/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/viewModels/ThreadsViewModel.kt +++ b/lib_smsmms_android/src/main/java/com/afkanerd/smswithoutborders_libsmsmms/ui/viewModels/ThreadsViewModel.kt @@ -7,6 +7,7 @@ import android.provider.Telephony import android.widget.Toast import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -20,6 +21,7 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.cachedIn +import androidx.paging.map import com.afkanerd.lib_smsmms_android.R import com.afkanerd.smswithoutborders_libsmsmms.data.entities.Threads import com.afkanerd.smswithoutborders_libsmsmms.extensions.context.ActivitiesConstant @@ -29,6 +31,7 @@ import com.afkanerd.smswithoutborders_libsmsmms.extensions.context.getDatabase import com.afkanerd.smswithoutborders_libsmsmms.extensions.context.isDefault import com.afkanerd.smswithoutborders_libsmsmms.extensions.context.loadRawSmsMmsDb import com.afkanerd.smswithoutborders_libsmsmms.extensions.context.loadRawThreads +import com.afkanerd.smswithoutborders_libsmsmms.extensions.context.retrieveContactName import com.afkanerd.smswithoutborders_libsmsmms.extensions.context.retrieveContactPhoto import com.afkanerd.smswithoutborders_libsmsmms.extensions.context.settingsGetDeleteSystem import com.afkanerd.smswithoutborders_libsmsmms.extensions.context.unblockContact @@ -41,11 +44,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -open class ThreadsViewModel: ViewModel() { + +open class ThreadsViewModel : ViewModel() { var messagesLoading by mutableStateOf(false) var secondaryMessagesLoading by mutableStateOf(false) @@ -69,7 +74,7 @@ open class ThreadsViewModel: ViewModel() { fun toggleDrawerValue() { viewModelScope.launch(AndroidUiDispatcher.Main) { _drawerState.value.apply { - if(isClosed) open() else close() + if (isClosed) open() else close() } } } @@ -98,16 +103,16 @@ open class ThreadsViewModel: ViewModel() { var initialLoadSize: Int = 2 * pageSize var maxSize: Int = PagingConfig.MAX_SIZE_UNBOUNDED - private var threadsPager: Flow>? = null - private var archivePager: Flow>? = null - private var draftsPager: Flow>? = null - private var mutePager: Flow>? = null - private var blockedPager: Flow>? = null + private var threadsPager: Flow>? = null + private var archivePager: Flow>? = null + private var draftsPager: Flow>? = null + private var mutePager: Flow>? = null + private var blockedPager: Flow>? = null - fun getThreads(context: Context): Flow> { - if(threadsPager == null) { + fun getThreads(context: Context): Flow> { + if (threadsPager == null) { threadsPager = Pager( - config=PagingConfig( + config = PagingConfig( pageSize, prefetchDistance, enablePlaceholder, @@ -117,15 +122,15 @@ open class ThreadsViewModel: ViewModel() { pagingSourceFactory = { context.getDatabase().threadsDao()!!.getThreads() } - ).flow.cachedIn(viewModelScope) + ).flow.enrich(this, context).cachedIn(viewModelScope) } return threadsPager!! } - fun getArchives(context: Context): Flow> { - if(archivePager == null) { + fun getArchives(context: Context): Flow> { + if (archivePager == null) { archivePager = Pager( - config=PagingConfig( + config = PagingConfig( pageSize, prefetchDistance, enablePlaceholder, @@ -135,15 +140,15 @@ open class ThreadsViewModel: ViewModel() { pagingSourceFactory = { context.getDatabase().threadsDao()!!.getArchived() } - ).flow.cachedIn(viewModelScope) + ).flow.enrich(this, context).cachedIn(viewModelScope) } return archivePager!! } - fun getDrafts(context: Context): Flow> { - if(draftsPager == null) { + fun getDrafts(context: Context): Flow> { + if (draftsPager == null) { draftsPager = Pager( - config=PagingConfig( + config = PagingConfig( pageSize, prefetchDistance, enablePlaceholder, @@ -154,15 +159,15 @@ open class ThreadsViewModel: ViewModel() { context.getDatabase().threadsDao()!! .getType(Telephony.Sms.MESSAGE_TYPE_DRAFT) } - ).flow.cachedIn(viewModelScope) + ).flow.enrich(this, context).cachedIn(viewModelScope) } return draftsPager!! } - fun getIsMute(context: Context): Flow> { - if(mutePager == null) { + fun getIsMute(context: Context): Flow> { + if (mutePager == null) { mutePager = Pager( - config=PagingConfig( + config = PagingConfig( pageSize, prefetchDistance, enablePlaceholder, @@ -172,15 +177,15 @@ open class ThreadsViewModel: ViewModel() { pagingSourceFactory = { context.getDatabase().threadsDao()!!.getIsMute() } - ).flow.cachedIn(viewModelScope) + ).flow.enrich(this, context).cachedIn(viewModelScope) } return mutePager!! } - fun getIsBlocked(context: Context): Flow> { - if(blockedPager == null) { + fun getIsBlocked(context: Context): Flow> { + if (blockedPager == null) { blockedPager = Pager( - config=PagingConfig( + config = PagingConfig( pageSize, prefetchDistance, enablePlaceholder, @@ -190,7 +195,7 @@ open class ThreadsViewModel: ViewModel() { pagingSourceFactory = { context.getDatabase().threadsDao()!!.getIsBlocked() } - ).flow.cachedIn(viewModelScope) + ).flow.enrich(this, context).cachedIn(viewModelScope) } return blockedPager!! } @@ -199,9 +204,11 @@ open class ThreadsViewModel: ViewModel() { viewModelScope.launch { withContext(Dispatchers.IO) { context.getDatabase().threadsDao()?.delete(threads) - if(context.settingsGetDeleteSystem) { - context.deleteSmsThreads(threads - .map { it.threadId.toString() }.toTypedArray()) + if (context.settingsGetDeleteSystem) { + context.deleteSmsThreads( + threads + .map { it.threadId.toString() }.toTypedArray() + ) } } } @@ -217,7 +224,7 @@ open class ThreadsViewModel: ViewModel() { withContext(Dispatchers.IO) { context.getDatabase().threadsDao()?.setIsBlocked(isBlocked, addresses) try { - if(isBlocked) { + if (isBlocked) { context.unblockContact(addresses) } else { context.blockContact(addresses) @@ -262,7 +269,7 @@ open class ThreadsViewModel: ViewModel() { secondaryMessagesLoading = true } messagesLoading = false - } catch(e: Exception) { + } catch (e: Exception) { e.printStackTrace() } finally { withContext(Dispatchers.Main) { @@ -318,7 +325,8 @@ open class ThreadsViewModel: ViewModel() { return try { val res: Bundle? = context.contentResolver.call( - AUTHORITY_URI, METHOD_IS_BLOCKED, thread.address, null); + AUTHORITY_URI, METHOD_IS_BLOCKED, thread.address, null + ); res != null && res.getBoolean(RES_NUMBER_IS_BLOCKED, false); } catch (e: Exception) { e.printStackTrace() @@ -374,7 +382,7 @@ open class ThreadsViewModel: ViewModel() { class ContactRepository() { fun contactPhoto(context: Context, phoneNumber: String): Flow = flow { - val uri = if(!context.isDefault()) "" else context.retrieveContactPhoto(phoneNumber) + val uri = if (!context.isDefault()) "" else context.retrieveContactPhoto(phoneNumber) emit(uri) }.flowOn(Dispatchers.IO) } @@ -386,32 +394,37 @@ open class ThreadsViewModel: ViewModel() { } } - class Migrations(private val threadsViewModel: ThreadsViewModel){ + class Migrations(private val threadsViewModel: ThreadsViewModel) { private val dbV2Migration = "dbV2Migration" private fun Context.getMigratedV2(): Boolean { val sharedPreferences = getSharedPreferences( - ActivitiesConstant.ACTIVITIES_FILENAMES, Context.MODE_PRIVATE) + ActivitiesConstant.ACTIVITIES_FILENAMES, Context.MODE_PRIVATE + ) return sharedPreferences.getBoolean(dbV2Migration, false) } private fun Context.setMigratedV2(load: Boolean) { val sharedPreferences = getSharedPreferences( - ActivitiesConstant.ACTIVITIES_FILENAMES, Context.MODE_PRIVATE) + ActivitiesConstant.ACTIVITIES_FILENAMES, Context.MODE_PRIVATE + ) return sharedPreferences.edit { putBoolean(dbV2Migration, load) } } + fun migrateV1ToV2(context: Context) { - if(context.isDefault()) { + if (context.isDefault()) { val roomVersion = context.getDatabase().openHelper.readableDatabase.version - if(roomVersion == 2 && !context.getMigratedV2()) { + if (roomVersion == 2 && !context.getMigratedV2()) { threadsViewModel.loadNativesAsync(context) { CoroutineScope(Dispatchers.Main).launch { context.setMigratedV2(true) - Toast.makeText(context, + Toast.makeText( + context, context.getString(R.string.secure_database_migrated), - Toast.LENGTH_SHORT).show() + Toast.LENGTH_SHORT + ).show() } } } @@ -422,4 +435,71 @@ open class ThreadsViewModel: ViewModel() { } -} \ No newline at end of file +} + +/** + * This function maps a flow of paging data, such that it contains + * necessary information that is frequently computed. + * In this way, we reduce repetitive work that was previously done on the UI. + */ +fun Flow>.enrich( + threadsViewModel: ThreadsViewModel, + context: Context, +): Flow> { + return this.map { page -> + page.map { ThreadsExtended(it, threadsViewModel, context) } + } +} + + +/** + * This class provides access to the same fields that a Threads object has, + * while providing useful additional fields. + * The additional fields are mostly about values that are lazily computed and cached. + */ +@Immutable +data class ThreadsExtended( + val raw: Threads, + private val threadsViewModel: ThreadsViewModel, + private val context: Context +) { + + val contactName by lazy { + // Now, why? query the system to fetch a contact for a sender id, when it's technically impossible + // to store a contact whose "number" is a text-based sender id? + if (!this.canBeContact) { + raw.address + } else { + context.retrieveContactName(raw.address) + } + } + + val contactPhotoUri by lazy { + // Now, why search for contact photo for an address that is not saveable as a contact? + // Where should the contact photo come from? + if (!this.canBeContact) return@lazy null + + threadsViewModel + .contactPhoto(context, raw.address).value + } + + val isContact by lazy { + if (!this.canBeContact) { + false + } else { + !contactName.isNullOrBlank() + } + } + + /** + * This field tells us if the address is saveable as a contact. + * This helps us reduce unnecessary computation in other areas. + */ + val canBeContact by lazy { + // The address can be a contact, if it starts with a numeric value + // When checking, let's not check the entire sequence. Let's further cut costs, by + // checking only the first character. + Regex("^[0-9+]$").matches(raw.address.first().toString()) + } + +}