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..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,7 +1,7 @@ package com.afkanerd.smswithoutborders_libsmsmms.ui +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 +33,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 +55,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 @@ -85,12 +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.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey -import coil3.Uri -import coil3.toUri 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,8 +109,8 @@ 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.launch +import kotlin.collections.map import kotlin.math.roundToInt data class ThreadsConversationParameters( @@ -151,7 +146,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 +168,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 +203,8 @@ fun ThreadConversationLayout( val scope = rememberCoroutineScope() + + BackHandler( inboxType != ThreadsViewModel.InboxType.INBOX || !selectedItems.isEmpty() @@ -563,12 +564,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, @@ -625,9 +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 enriched = displayedInbox[index] ?: return@items + val thread = enriched.raw; val offsetX = remember { Animatable(0f) } val threshold = 300f @@ -700,24 +703,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 +717,12 @@ fun ThreadConversationLayout( ThreadConversationCard( id = thread.threadId, - name = contactName ?: address, + name = enriched.contactName ?: thread.address, content = thread.snippet, date = date, isRead = !thread.unread, - isContact = isDefault && !contactName.isNullOrBlank(), - isBlocked = isBlocked, + isContact = enriched.isContact, + isBlocked = inboxType == ThreadsViewModel.InboxType.BLOCKED, isPinned = thread.isPinned, modifier = Modifier.combinedClickable( onClick = { @@ -784,7 +769,7 @@ fun ThreadConversationLayout( type = thread.type, unreadCount = thread.unreadCount, mms = thread.isMms, - contactPhotoUri = contactPhotoUri, + contactPhotoUri = enriched.contactPhotoUri, ) } } @@ -816,6 +801,7 @@ fun ThreadConversationLayout( } + @Preview @Composable fun PreviewMessageCard() { 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()) + } + +}