/* * Copyright (C) 2017 Moez Bhatti * * This file is part of QKSMS. * * QKSMS is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * QKSMS is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with QKSMS. If not, see . */ package feature.compose import android.content.Context import android.content.Intent import android.net.Uri import android.telephony.PhoneNumberUtils import android.telephony.SmsMessage import android.view.inputmethod.EditorInfo import com.moez.QKSMS.R import com.uber.autodispose.android.lifecycle.scope import com.uber.autodispose.kotlin.autoDisposable import common.Navigator import common.base.QkViewModel import common.util.ClipboardUtils import common.util.extensions.makeToast import filter.ContactFilter import interactor.CancelDelayedMessage import interactor.ContactSync import interactor.DeleteMessages import interactor.MarkRead import interactor.RetrySending import interactor.SendMessage import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxkotlin.Observables import io.reactivex.rxkotlin.plusAssign import io.reactivex.rxkotlin.withLatestFrom import io.reactivex.schedulers.Schedulers import io.reactivex.subjects.BehaviorSubject import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject import io.realm.RealmList import manager.PermissionManager import model.Attachment import model.Contact import model.Conversation import model.Message import model.PhoneNumber import repository.ContactRepository import repository.MessageRepository import util.SubscriptionUtils import util.extensions.asObservable import util.extensions.isImage import util.extensions.mapNotNull import util.extensions.removeAccents import util.tryOrNull import java.net.URLDecoder import java.util.* import java.util.concurrent.TimeUnit import javax.inject.Inject class ComposeViewModel @Inject constructor( private val intent: Intent, private val context: Context, private val cancelMessage: CancelDelayedMessage, private val contactFilter: ContactFilter, private val contactsRepo: ContactRepository, private val deleteMessages: DeleteMessages, private val markRead: MarkRead, private val messageRepo: MessageRepository, private val navigator: Navigator, private val permissionManager: PermissionManager, private val retrySending: RetrySending, private val sendMessage: SendMessage, private val subUtils: SubscriptionUtils, private val syncContacts: ContactSync ) : QkViewModel(ComposeState(query = intent.extras?.getString("query") ?: "")) { private var sharedText: String = intent.extras?.getString(Intent.EXTRA_TEXT) ?: "" private val attachments: Subject> = BehaviorSubject.createDefault(ArrayList()) private val contacts: Observable> by lazy { contactsRepo.getUnmanagedContacts().toObservable() } private val contactsReducer: Subject<(List) -> List> = PublishSubject.create() private val selectedContacts: Observable> private val searchResults: Subject> = BehaviorSubject.create() private val searchSelection: Subject = BehaviorSubject.createDefault(-1) private val conversation: Subject = BehaviorSubject.create() private val messages: Subject> = BehaviorSubject.create() init { // If there are any image attachments, we'll set those as the initial attachments for the // conversation val sharedImages = mutableListOf() intent.getParcelableExtra(Intent.EXTRA_STREAM)?.run { sharedImages += this } intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.run { sharedImages += this } attachments.onNext(sharedImages.map { uri -> Attachment(uri) }) val threadId = intent.extras?.getLong("threadId") ?: 0L var address = "" intent.data?.let { val data = it.toString() address = when { it.scheme.startsWith("smsto") -> data.replace("smsto:", "") it.scheme.startsWith("mmsto") -> data.replace("mmsto:", "") it.scheme.startsWith("sms") -> data.replace("sms:", "") it.scheme.startsWith("mms") -> data.replace("mms:", "") else -> "" } // The dialer app on Oreo sends a URL encoded string, make sure to decode it if (address.contains('%')) address = URLDecoder.decode(address, "UTF-8") } val initialConversation: Observable = when { threadId != 0L -> { newState { copy(selectedConversation = threadId, editingMode = false) } messageRepo.getConversationAsync(threadId).asObservable() } address.isNotBlank() -> { newState { copy(editingMode = false) } Observable.just(address) .mapNotNull { messageRepo.getOrCreateConversation(it) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } else -> { newState { copy(editingMode = true) } Observable.empty() } } selectedContacts = contactsReducer .scan(listOf(), { previousState, reducer -> reducer(previousState) }) .doOnNext { contacts -> newState { copy(selectedContacts = contacts) } } // Merges two potential conversation sources (threadId from constructor and contact selection) into a single // stream of conversations. If the conversation was deleted, notify the activity to shut down disposables += selectedContacts .skipUntil(state.filter { state -> state.editingMode }) .takeUntil(state.filter { state -> !state.editingMode }) .map { contacts -> contacts.map { it.numbers.firstOrNull()?.address ?: "" } } .observeOn(Schedulers.computation()) .doOnNext { newState { copy(loading = true) } } .mapNotNull { addresses -> val conversation = messageRepo.getOrCreateConversation(addresses) newState { copy(loading = false) } conversation } .observeOn(AndroidSchedulers.mainThread()) .mergeWith(initialConversation) .filter { conversation -> conversation.isLoaded } .doOnNext { conversation -> if (!conversation.isValid) { newState { copy(hasError = true) } } } .filter { conversation -> conversation.isValid } .filter { conversation -> conversation.id != 0L } .distinctUntilChanged() .subscribe { conversation.onNext(it) } // When the conversation changes, update the threadId and the messages for the adapter disposables += conversation .distinctUntilChanged { conversation -> conversation.id } .observeOn(AndroidSchedulers.mainThread()) .map { conversation -> val messages = messageRepo.getMessages(conversation.id) newState { copy(selectedConversation = conversation.id, messages = Pair(conversation, messages)) } messages } .switchMap { messages -> messages.asObservable() } .subscribe { messages.onNext(it) } disposables += conversation .map { conversation -> conversation.getTitle() } .distinctUntilChanged() .subscribe { title -> newState { copy(conversationtitle = title) } } disposables += attachments .subscribe { attachments -> newState { copy(attachments = attachments) } } disposables += conversation .map { conversation -> conversation.id } .distinctUntilChanged() .withLatestFrom(state) { id, state -> messageRepo.getMessages(id, state.query) } .switchMap { messages -> messages.asObservable() } .takeUntil(state.map { it.query }.filter { it.isEmpty() }) .filter { messages -> messages.isLoaded } .filter { messages -> messages.isValid } .subscribe(searchResults::onNext) disposables += Observables.combineLatest(searchSelection, searchResults) { selected, messages -> if (selected == -1L) { messages.lastOrNull()?.let { message -> searchSelection.onNext(message.id) } } else { val position = messages.indexOfFirst { it.id == selected } + 1 newState { copy(searchSelectionPosition = position, searchResults = messages.size) } } }.subscribe() val latestSubId = messages .map { messages -> messages.lastOrNull()?.subId ?: -1 } .distinctUntilChanged() disposables += Observables.combineLatest(latestSubId, subUtils.subscriptionsObservable) { subId, subs -> val sub = if (subs.size > 1) subs.firstOrNull { it.subscriptionId == subId } ?: subs[0] else null newState { copy(subscription = sub) } }.subscribe() if (threadId == 0L) { syncContacts.execute(Unit) } } override fun bindView(view: ComposeView) { super.bindView(view) // Set the contact suggestions list to visible at all times when in editing mode and there are no contacts // selected yet, and also visible while in editing mode and there is text entered in the query field Observables .combineLatest(view.queryChangedIntent, selectedContacts, { query, selectedContacts -> selectedContacts.isEmpty() || query.isNotEmpty() }) .skipUntil(state.filter { state -> state.editingMode }) .takeUntil(state.filter { state -> !state.editingMode }) .distinctUntilChanged() .autoDisposable(view.scope()) .subscribe { contactsVisible -> newState { copy(contactsVisible = contactsVisible && editingMode) } } // Update the list of contact suggestions based on the query input, while also filtering out any contacts // that have already been selected Observables .combineLatest(view.queryChangedIntent, contacts, selectedContacts, { query, contacts, selectedContacts -> // Strip the accents from the query. This can be an expensive operation, so // cache the result instead of doing it for each contact val normalizedQuery = query.removeAccents() var filteredContacts = contacts .filterNot { contact -> selectedContacts.contains(contact) } .filter { contact -> contactFilter.filter(contact, normalizedQuery) } // If the entry is a valid destination, allow it as a recipient if (PhoneNumberUtils.isWellFormedSmsAddress(query.toString())) { val newAddress = PhoneNumberUtils.formatNumber(query.toString(), Locale.getDefault().country) val newContact = Contact(numbers = RealmList(PhoneNumber(address = newAddress ?: query.toString()))) filteredContacts = listOf(newContact) + filteredContacts } filteredContacts }) .skipUntil(state.filter { state -> state.editingMode }) .takeUntil(state.filter { state -> !state.editingMode }) .subscribeOn(Schedulers.computation()) .autoDisposable(view.scope()) .subscribe { contacts -> newState { copy(contacts = contacts) } } // Backspaces should delete the most recent contact if there's no text input // Close the activity if user presses back view.queryBackspaceIntent .withLatestFrom(selectedContacts, view.queryChangedIntent, { event, contacts, query -> if (contacts.isNotEmpty() && query.isEmpty()) { contactsReducer.onNext { it.dropLast(1) } } }) .autoDisposable(view.scope()) .subscribe() // Enter the first contact suggestion if the enter button is pressed view.queryEditorActionIntent .filter { actionId -> actionId == EditorInfo.IME_ACTION_DONE } .withLatestFrom(state, { _, state -> state }) .autoDisposable(view.scope()) .subscribe { state -> state.contacts.firstOrNull()?.let { contact -> contactsReducer.onNext { contacts -> contacts + contact } } } // Update the list of selected contacts when a new contact is selected or an existing one is deselected Observable.merge( view.chipDeletedIntent.doOnNext { contact -> contactsReducer.onNext { contacts -> contacts.filterNot { it == contact } } }, view.chipSelectedIntent.doOnNext { contact -> contactsReducer.onNext { contacts -> contacts.toMutableList().apply { add(contact) } } }) .skipUntil(state.filter { state -> state.editingMode }) .takeUntil(state.filter { state -> !state.editingMode }) .autoDisposable(view.scope()) .subscribe() // When the menu is loaded, trigger a new state so that the menu options can be rendered correctly view.menuReadyIntent .autoDisposable(view.scope()) .subscribe { newState { copy() } } // Open the phone dialer if the call button is clicked view.optionsItemIntent .filter { it == R.id.call } .withLatestFrom(conversation, { _, conversation -> conversation }) .map { conversation -> conversation.recipients.first() } .map { recipient -> recipient.address } .autoDisposable(view.scope()) .subscribe { address -> navigator.makePhoneCall(address) } // Open the conversation settings if info button is clicked view.optionsItemIntent .filter { it == R.id.info } .withLatestFrom(conversation, { _, conversation -> conversation }) .autoDisposable(view.scope()) .subscribe { conversation -> navigator.showConversationInfo(conversation.id) } // Copy the message contents view.optionsItemIntent .filter { it == R.id.copy } .withLatestFrom(view.messagesSelectedIntent, { _, messages -> messages?.firstOrNull()?.let { messageRepo.getMessage(it) }?.let { message -> ClipboardUtils.copy(context, message.getText()) context.makeToast(R.string.toast_copied) } }) .autoDisposable(view.scope()) .subscribe { view.clearSelection() } // Delete the messages view.optionsItemIntent .filter { it == R.id.delete } .withLatestFrom(view.messagesSelectedIntent, conversation, { _, messages, conversation -> deleteMessages.execute(DeleteMessages.Params(messages, conversation.id)) }) .autoDisposable(view.scope()) .subscribe { view.clearSelection() } // Forward the message view.optionsItemIntent .filter { it == R.id.forward } .withLatestFrom(view.messagesSelectedIntent, { _, messages -> messages?.firstOrNull()?.let { messageRepo.getMessage(it) }?.let { message -> val images = message.parts.filter { it.isImage() }.mapNotNull { it.getUri() } navigator.showCompose(message.body, images) } }) .autoDisposable(view.scope()) .subscribe { view.clearSelection() } // Show the previous search result view.optionsItemIntent .filter { it == R.id.previous } .withLatestFrom(searchSelection, searchResults) { _, selection, messages -> val currentPosition = messages.indexOfFirst { it.id == selection } if (currentPosition <= 0L) messages.lastOrNull()?.id ?: -1 else messages.getOrNull(currentPosition - 1)?.id ?: -1 } .filter { id -> id != -1L } .autoDisposable(view.scope()) .subscribe(searchSelection) // Show the next search result view.optionsItemIntent .filter { it == R.id.next } .withLatestFrom(searchSelection, searchResults) { _, selection, messages -> val currentPosition = messages.indexOfFirst { it.id == selection } if (currentPosition >= messages.size - 1) messages.firstOrNull()?.id ?: -1 else messages.getOrNull(currentPosition + 1)?.id ?: -1 } .filter { id -> id != -1L } .autoDisposable(view.scope()) .subscribe(searchSelection) // Clear the search view.optionsItemIntent .filter { it == R.id.clear } .autoDisposable(view.scope()) .subscribe { newState { copy(query = "", searchSelectionId = -1) } } // Toggle the group sending mode view.sendAsGroupIntent .autoDisposable(view.scope()) .subscribe { newState { copy(sendAsGroup = !sendAsGroup) } } // Scroll to search position searchSelection .filter { id -> id != -1L } .doOnNext { id -> newState { copy(searchSelectionId = id) } } .autoDisposable(view.scope()) .subscribe(view::scrollToMessage) // Retry sending view.messageClickIntent .filter { message -> message.isFailedMessage() } .doOnNext { message -> retrySending.execute(message) } .autoDisposable(view.scope()) .subscribe() // Update the State when the message selected count changes view.messagesSelectedIntent .map { selection -> selection.size } .autoDisposable(view.scope()) .subscribe { messages -> newState { copy(selectedMessages = messages, editingMode = false) } } // Cancel sending a message view.cancelSendingIntent .doOnNext { message -> view.setDraft(message.getText()) } .autoDisposable(view.scope()) .subscribe { message -> cancelMessage.execute(message.id) } // Save draft when the activity goes into the background view.activityVisibleIntent .filter { visible -> !visible } .withLatestFrom(conversation, { _, conversation -> conversation.id }) .observeOn(Schedulers.io()) .withLatestFrom(view.textChangedIntent, { threadId, draft -> messageRepo.saveDraft(threadId, draft.toString()) }) .autoDisposable(view.scope()) .subscribe() // Mark the conversation read, if in foreground Observables.combineLatest(messages, view.activityVisibleIntent, { _, visible -> visible }) .filter { visible -> visible } .withLatestFrom(conversation, { _, conversation -> conversation }) .mapNotNull { conversation -> conversation.takeIf { it.isValid }?.id } .debounce(200, TimeUnit.MILLISECONDS) .autoDisposable(view.scope()) .subscribe { threadId -> markRead.execute(threadId) } // Open the attachment options view.attachIntent .autoDisposable(view.scope()) .subscribe { newState { copy(attaching = !attaching) } } // Attach a photo from camera view.cameraIntent .autoDisposable(view.scope()) .subscribe { when (permissionManager.hasStorage()) { true -> view.requestCamera() false -> view.requestStoragePermission() } } // Attach a photo from gallery view.galleryIntent .autoDisposable(view.scope()) .subscribe { view.requestGallery() } // A photo was selected Observable.merge( view.attachmentSelectedIntent.map { uri -> Attachment(uri) }, view.inputContentIntent.map { inputContent -> Attachment(inputContent = inputContent) }) .withLatestFrom(attachments, { attachment, attachments -> attachments + attachment }) .doOnNext { attachments.onNext(it) } .autoDisposable(view.scope()) .subscribe { newState { copy(attaching = false) } } // Detach a photo view.attachmentDeletedIntent .withLatestFrom(attachments, { bitmap, attachments -> attachments.filter { it !== bitmap } }) .autoDisposable(view.scope()) .subscribe { attachments.onNext(it) } conversation .map { conversation -> conversation.draft } .distinctUntilChanged() .autoDisposable(view.scope()) .subscribe { draft -> // If text was shared into the conversation, it should take priority over the // existing draft // // TODO: Show dialog warning user about overwriting draft if (sharedText.isNotBlank()) { view.setDraft(sharedText) } else { view.setDraft(draft) } } // Enable the send button when there is text input into the new message body or there's // an attachment, disable otherwise Observables .combineLatest(view.textChangedIntent, attachments, { text, attachments -> text.isNotBlank() || attachments.isNotEmpty() }) .autoDisposable(view.scope()) .subscribe { canSend -> newState { copy(canSend = canSend) } } // Show the remaining character counter when necessary view.textChangedIntent .observeOn(Schedulers.computation()) .mapNotNull { draft -> tryOrNull { SmsMessage.calculateLength(draft, false) } } .map { array -> val messages = array[0] val remaining = array[2] when { messages <= 1 && remaining > 10 -> "" messages <= 1 && remaining <= 10 -> "$remaining" else -> "$remaining / $messages" } } .distinctUntilChanged() .autoDisposable(view.scope()) .subscribe { remaining -> newState { copy(remaining = remaining) } } // Toggle to the next sim slot view.changeSimIntent .withLatestFrom(state) { _, state -> val subs = subUtils.subscriptions val subIndex = subs.indexOfFirst { it.subscriptionId == state.subscription?.subscriptionId } val subscription = when { subIndex == -1 -> null subIndex < subs.size - 1 -> subs[subIndex + 1] else -> subs[0] } newState { copy(subscription = subscription) } } .autoDisposable(view.scope()) .subscribe() // Send a message when the send button is clicked, and disable editing mode if it's enabled view.sendIntent .withLatestFrom(view.textChangedIntent, { _, body -> body }) .map { body -> body.toString() } .withLatestFrom(state, attachments, conversation, selectedContacts, { body, state, attachments, conversation, contacts -> val subId = state.subscription?.subscriptionId ?: -1 if (state.sendAsGroup) { val threadId = conversation.id val addresses = conversation.recipients.map { it.address } sendMessage.execute(SendMessage.Params(subId, threadId, addresses, body, attachments)) } else { contacts .map { contact -> contact.numbers } .mapNotNull { numbers -> numbers.firstOrNull() } .map { number -> number.address } .mapNotNull { address -> messageRepo.getOrCreateConversation(address) } .filter { it.recipients.isNotEmpty() } .forEach { val address = it.recipients.map { it.address } sendMessage.execute(SendMessage.Params(subId, it.id, address, body, attachments)) } } view.setDraft("") this.attachments.onNext(ArrayList()) if (state.editingMode) { newState { copy(editingMode = false, sendAsGroup = true, hasError = !state.sendAsGroup) } } }) .autoDisposable(view.scope()) .subscribe() // Navigate back view.optionsItemIntent .filter { it == android.R.id.home } .map { Unit } .mergeWith(view.backPressedIntent) .withLatestFrom(state, { _, state -> when { state.selectedMessages > 0 -> view.clearSelection() else -> newState { copy(hasError = true) } } }) .autoDisposable(view.scope()) .subscribe() } }