diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000000000000000000000000000000000000..d524fe69baadd7f3cb6034f3ad979569a0adc5a9 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,85 @@ +version: 2 + +jobs: + build: + working_directory: ~/code + docker: + - image: circleci/android:api-29 + steps: + - checkout + - restore_cache: + key: jars-{{ checksum "build.gradle" }}-{{ checksum "presentation/build.gradle" }}-{{ checksum "data/build.gradle" }}-{{ checksum "domain/build.gradle" }} + - run: + name: Download dependencies + command: ./gradlew androidDependencies + - save_cache: + paths: + - ~/.gradle + key: jars-{{ checksum "build.gradle" }}-{{ checksum "presentation/build.gradle" }}-{{ checksum "data/build.gradle" }}-{{ checksum "domain/build.gradle" }} + - run: + name: Decrypt and unzip secrets + command: | + openssl aes-256-cbc -d -in secrets.tar.enc -md sha1 -k ${SECRETS_KEY} -iv ${SECRETS_IV} >> secrets.tar + tar xvf secrets.tar + - run: + name: Gradle build + command: ./gradlew :presentation:assembleWithAnalyticsRelease :presentation:bundleWithAnalyticsRelease + - run: + name: Flatten outputs + command: find presentation/build/outputs -mindepth 2 -type f -exec mv -i '{}' presentation/build/outputs/ ';' + - store_artifacts: + path: presentation/build/outputs + - persist_to_workspace: + root: presentation/build/outputs + paths: . + + test: + working_directory: ~/code + docker: + - image: circleci/android:api-29 + steps: + - checkout + - restore_cache: + key: jars-{{ checksum "build.gradle" }}-{{ checksum "presentation/build.gradle" }}-{{ checksum "data/build.gradle" }}-{{ checksum "domain/build.gradle" }} + - run: + name: Download dependencies + command: ./gradlew androidDependencies + - save_cache: + paths: + - ~/.gradle + key: jars-{{ checksum "build.gradle" }}-{{ checksum "presentation/build.gradle" }}-{{ checksum "data/build.gradle" }}-{{ checksum "domain/build.gradle" }} + - store_test_results: + path: presentation/build/test-results + + deploy: + docker: + - image: cibuilds/github:0.10 + steps: + - attach_workspace: + at: presentation/build/outputs + - run: + name: "Publish Release on GitHub" + command: ghr -t ${GITHUB_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} ${CIRCLE_TAG} presentation/build/outputs/ + +workflows: + version: 2 + main: + jobs: + - build: + filters: + tags: + only: /^v.*/ + - test: + requires: + - build + filters: + tags: + only: /^v.*/ + - deploy: + requires: + - test + filters: + branches: + ignore: /.*/ + tags: + only: /^v.*/ diff --git a/README.md b/README.md index a795fb5f7c4ffbd677463939fc2564c0b672e87d..e8aa5ec4a463e40b61d925994b66c15c3b2509b7 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,6 @@ Message is an open source replacement to the stock messaging app on Android. Message is forked from [QKSMS](https://github.com/moezbhatti/qksms) -## Authors - [Authors](https://gitlab.e.foundation/e/apps/Message/-/blob/master/AUTHORS) ## Release Notes diff --git a/android-smsmms/src/main/java/com/android/mms/service_alt/DownloadRequest.java b/android-smsmms/src/main/java/com/android/mms/service_alt/DownloadRequest.java index c413ca74eb20892839971f21982436ca86ffae8b..b706794975ee631f9d38c65764981a7665086379 100755 --- a/android-smsmms/src/main/java/com/android/mms/service_alt/DownloadRequest.java +++ b/android-smsmms/src/main/java/com/android/mms/service_alt/DownloadRequest.java @@ -169,7 +169,7 @@ public class DownloadRequest extends MmsRequest { // } // Store the downloaded message final PduPersister persister = PduPersister.getPduPersister(context); - final Uri messageUri = persister.persist(pdu, Telephony.Mms.Inbox.CONTENT_URI, true, true, null); + final Uri messageUri = persister.persist(pdu, Telephony.Mms.Inbox.CONTENT_URI, PduPersister.DUMMY_THREAD_ID, true, true, null); if (messageUri == null) { Timber.e("DownloadRequest.persistIfRequired: can not persist message"); return null; diff --git a/android-smsmms/src/main/java/com/android/mms/transaction/DownloadManager.java b/android-smsmms/src/main/java/com/android/mms/transaction/DownloadManager.java index 5ac48647f1b378c781c434f800c4aa61f37418f2..698817ca6d3364cfbd1325e688492837f123639c 100755 --- a/android-smsmms/src/main/java/com/android/mms/transaction/DownloadManager.java +++ b/android-smsmms/src/main/java/com/android/mms/transaction/DownloadManager.java @@ -80,8 +80,6 @@ public class DownloadManager { String httpParams = MmsConfig.getHttpParams(); if (!TextUtils.isEmpty(httpParams)) { configOverrides.putString(SmsManager.MMS_CONFIG_HTTP_PARAMS, httpParams); - } else { - configOverrides = smsManager.getCarrierConfigValues(); } grantUriPermission(context, contentUri); diff --git a/android-smsmms/src/main/java/com/android/mms/transaction/NotificationTransaction.java b/android-smsmms/src/main/java/com/android/mms/transaction/NotificationTransaction.java index 074586bda94db7ed8e0bd1bbf46079fe069671f7..1b03c0bab14c5fd8c67dcc6febcbe96416df4259 100755 --- a/android-smsmms/src/main/java/com/android/mms/transaction/NotificationTransaction.java +++ b/android-smsmms/src/main/java/com/android/mms/transaction/NotificationTransaction.java @@ -110,9 +110,8 @@ public class NotificationTransaction extends Transaction implements Runnable { try { // Save the pdu. If we can start downloading the real pdu immediately, don't allow // persist() to create a thread for the notificationInd because it causes UI jank. - mUri = PduPersister.getPduPersister(context).persist( - ind, Inbox.CONTENT_URI, !allowAutoDownload(mContext), - true, null); + mUri = PduPersister.getPduPersister(context).persist(ind, Inbox.CONTENT_URI, + PduPersister.DUMMY_THREAD_ID, !allowAutoDownload(mContext), true, null); } catch (MmsException e) { Timber.e(e, "Failed to save NotificationInd in constructor."); throw new IllegalArgumentException(); @@ -185,8 +184,8 @@ public class NotificationTransaction extends Transaction implements Runnable { } else { // Save the received PDU (must be a M-RETRIEVE.CONF). PduPersister p = PduPersister.getPduPersister(mContext); - Uri uri = p.persist(pdu, Inbox.CONTENT_URI, true, - true, null); + Uri uri = p.persist(pdu, Inbox.CONTENT_URI, PduPersister.DUMMY_THREAD_ID, + true, true, null); RetrieveConf retrieveConf = (RetrieveConf) pdu; diff --git a/android-smsmms/src/main/java/com/android/mms/transaction/PushReceiver.java b/android-smsmms/src/main/java/com/android/mms/transaction/PushReceiver.java index a701be4b7d070e17c95f54a32d192ff0f2b70993..722b2f834182c63c9052baa35e97902ef5002153 100755 --- a/android-smsmms/src/main/java/com/android/mms/transaction/PushReceiver.java +++ b/android-smsmms/src/main/java/com/android/mms/transaction/PushReceiver.java @@ -107,8 +107,8 @@ public class PushReceiver extends BroadcastReceiver { break; } - Uri uri = p.persist(pdu, Uri.parse("content://mms/inbox"), true, - true, null); + Uri uri = p.persist(pdu, Uri.parse("content://mms/inbox"), + PduPersister.DUMMY_THREAD_ID, true, true, null); // Update thread ID for ReadOrigInd & DeliveryInd. ContentValues values = new ContentValues(1); values.put(Mms.THREAD_ID, threadId); @@ -136,7 +136,8 @@ public class PushReceiver extends BroadcastReceiver { // Save the pdu. If we can start downloading the real pdu immediately, // don't allow persist() to create a thread for the notificationInd // because it causes UI jank. - Uri uri = p.persist(pdu, Inbox.CONTENT_URI, true, true, null); + Uri uri = p.persist(pdu, Inbox.CONTENT_URI, + PduPersister.DUMMY_THREAD_ID, true, true, null); String location = getContentLocation(mContext, uri); if (downloadedUrls.contains(location)) { diff --git a/android-smsmms/src/main/java/com/android/mms/transaction/RetrieveTransaction.java b/android-smsmms/src/main/java/com/android/mms/transaction/RetrieveTransaction.java index a9ea47b21968ed734f699e4e5ff46b410a38c76a..4de1c9b8a83177c8bbdf9881be1a675fe31afaf0 100755 --- a/android-smsmms/src/main/java/com/android/mms/transaction/RetrieveTransaction.java +++ b/android-smsmms/src/main/java/com/android/mms/transaction/RetrieveTransaction.java @@ -153,8 +153,8 @@ public class RetrieveTransaction extends Transaction implements Runnable { } else { // Store M-Retrieve.conf into Inbox PduPersister persister = PduPersister.getPduPersister(mContext); - msgUri = persister.persist(retrieveConf, Inbox.CONTENT_URI, true, - true, null); + msgUri = persister.persist(retrieveConf, Inbox.CONTENT_URI, + PduPersister.DUMMY_THREAD_ID, true, true, null); // Use local time instead of PDU time ContentValues values = new ContentValues(3); diff --git a/android-smsmms/src/main/java/com/google/android/mms/pdu_alt/PduPersister.java b/android-smsmms/src/main/java/com/google/android/mms/pdu_alt/PduPersister.java index 638b4c9abcf5b7e0fcb7e903fe7c9bbfea136dd7..07bcb551f0f1cf454de17326bd81215c5a79e52a 100755 --- a/android-smsmms/src/main/java/com/google/android/mms/pdu_alt/PduPersister.java +++ b/android-smsmms/src/main/java/com/google/android/mms/pdu_alt/PduPersister.java @@ -67,7 +67,7 @@ import java.util.Set; public class PduPersister { private static final boolean LOCAL_LOGV = false; - private static final long DUMMY_THREAD_ID = Long.MAX_VALUE; + public static final long DUMMY_THREAD_ID = Long.MAX_VALUE; private static final int DEFAULT_SUBSCRIPTION = 0; private static final int MAX_TEXT_BODY_SIZE = 300 * 1024; @@ -1263,6 +1263,7 @@ public class PduPersister { * * @param pdu The PDU object to be stored. * @param uri Where to store the given PDU object. + * @param threadId * @param createThreadId if true, this function may create a thread id for the recipients * @param groupMmsEnabled if true, all of the recipients addressed in the PDU will be used * to create the associated thread. When false, only the sender will be used in finding or @@ -1271,7 +1272,7 @@ public class PduPersister { * @return A Uri which can be used to access the stored PDU. */ - public Uri persist(GenericPdu pdu, Uri uri, boolean createThreadId, boolean groupMmsEnabled, + public Uri persist(GenericPdu pdu, Uri uri, long threadId, boolean createThreadId, boolean groupMmsEnabled, HashMap preOpenedFiles) throws MmsException { if (uri == null) { @@ -1400,8 +1401,7 @@ public class PduPersister { loadRecipients(PduHeaders.TO, recipients, addressMap, false); break; } - long threadId = DUMMY_THREAD_ID; - if (createThreadId && !recipients.isEmpty()) { + if (threadId == DUMMY_THREAD_ID && createThreadId && !recipients.isEmpty()) { // Given all the recipients associated with this message, find (or create) the // correct thread. threadId = Threads.getOrCreateThreadId(mContext, recipients); diff --git a/android-smsmms/src/main/java/com/klinker/android/send_message/Transaction.kt b/android-smsmms/src/main/java/com/klinker/android/send_message/Transaction.kt index 5754845003225b4843123efee8251962acab6cc5..35912778fce454c089b98c3fdda7f6fbc3f38dcc 100755 --- a/android-smsmms/src/main/java/com/klinker/android/send_message/Transaction.kt +++ b/android-smsmms/src/main/java/com/klinker/android/send_message/Transaction.kt @@ -94,7 +94,7 @@ class Transaction @JvmOverloads constructor(private val context: Context, settin val sendReq = buildPdu(context, addresses, subject, parts) val persister = PduPersister.getPduPersister(context) - val messageUri = existingUri ?: persister.persist(sendReq, Uri.parse("content://mms/outbox"), true, true, null) + val messageUri = existingUri ?: persister.persist(sendReq, Uri.parse("content://mms/outbox"), threadId, true, true, null) val sentIntent = Intent(MMS_SENT) BroadcastUtils.addClassName(context, sentIntent, MMS_SENT) diff --git a/build.gradle b/build.gradle index 53d635bdb1c2d5ff98a7b4a0ec12081471c00636..fb7aa45006d3cc4c8235d8ad165154134c6c2d17 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ buildscript { ext.exoplayer_version = "2.8.1" ext.glide_version = "4.8.0" ext.junit_version = '4.12' - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.3.60' ext.lifecycle_version = '2.2.0' ext.material_version = '1.1.0' ext.mockito_version = '2.18.3' @@ -42,7 +42,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:3.4.2' + classpath 'com.android.tools.build:gradle:3.5.2' classpath 'com.google.gms:google-services:4.2.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'io.fabric.tools:gradle:1.29.0' @@ -76,3 +76,17 @@ subprojects { task clean(type: Delete) { delete rootProject.buildDir } + +subprojects { + afterEvaluate { + if (project.hasProperty('kapt')) { + kapt { + // we expect this closure to run over a org.jetbrains.kotlin.gradle.plugin.KaptExtension + javacOptions { + option("-source", "8") + option("-target", "8") + } + } + } + } +} diff --git a/common/build.gradle b/common/build.gradle index ec0f93d96a95cda713df38ddfeb88a9424475413..a893d1c184c792d8de3febcf74f8423af7634ccb 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -24,7 +24,7 @@ android { defaultConfig { minSdkVersion 21 - targetSdkVersion 28 // Don't upgrade to 29 until we support Scoped storage + targetSdkVersion 29 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/common/src/main/java/com/moez/QKSMS/common/util/extensions/GlobalExtensions.kt b/common/src/main/java/com/moez/QKSMS/common/util/extensions/GlobalExtensions.kt new file mode 100644 index 0000000000000000000000000000000000000000..69b16be1d9497bc18bcb5737a58723ae3a1b1c38 --- /dev/null +++ b/common/src/main/java/com/moez/QKSMS/common/util/extensions/GlobalExtensions.kt @@ -0,0 +1,5 @@ +package com.moez.QKSMS.common.util.extensions + +fun now(): Long { + return System.currentTimeMillis() +} diff --git a/data/build.gradle b/data/build.gradle index 215e8ce5f5b481469ed58594639db7a40abf014b..a78666df9b943c021e82cd0103f6704bc069704e 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -32,7 +32,7 @@ android { defaultConfig { minSdkVersion 21 - targetSdkVersion 28 // Don't upgrade to 29 until we support Scoped storage + targetSdkVersion 29 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" buildConfigField "String", "AMPLITUDE_API_KEY", "\"${System.getenv("AMPLITUDE_API_KEY")}\"" @@ -83,10 +83,10 @@ dependencies { // coroutines implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-reactive:$coroutines_version" + implementation 'com.android.installreferrer:installreferrer:1.1' implementation 'com.callcontrol:datashare:1.2.0' implementation "com.f2prateek.rx.preferences2:rx-preferences:$rx_preferences_version" implementation "com.jakewharton.timber:timber:$timber_version" diff --git a/data/src/main/java/com/moez/QKSMS/extensions/CollectionExtensions.kt b/data/src/main/java/com/moez/QKSMS/extensions/CollectionExtensions.kt new file mode 100644 index 0000000000000000000000000000000000000000..e22d70dd8518a34869149871c915c63d61355e6e --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/extensions/CollectionExtensions.kt @@ -0,0 +1,13 @@ +package com.moez.QKSMS.extensions + +inline fun Iterable.associateByNotNull(keySelector: (T) -> K?): Map { + val map = hashMapOf() + forEach { value -> + val key = keySelector(value) + if (key != null) { + map[key] = value + } + } + + return map +} diff --git a/data/src/main/java/com/moez/QKSMS/extensions/CursorExtensions.kt b/data/src/main/java/com/moez/QKSMS/extensions/CursorExtensions.kt index 1214d0be8071a2c956c5d55a6868d8a6e41960f1..a23e95b2517d00bdab28e2a36781ac84d9879018 100644 --- a/data/src/main/java/com/moez/QKSMS/extensions/CursorExtensions.kt +++ b/data/src/main/java/com/moez/QKSMS/extensions/CursorExtensions.kt @@ -20,8 +20,6 @@ package com.moez.QKSMS.extensions import android.database.Cursor import io.reactivex.Flowable -import io.reactivex.Maybe -import io.reactivex.subjects.MaybeSubject fun Cursor.forEach(closeOnComplete: Boolean = true, method: (Cursor) -> Unit = {}) { moveToPosition(-1) @@ -41,21 +39,6 @@ fun Cursor.map(map: (Cursor) -> T): List { } } -fun Cursor.mapWhile(map: (Cursor) -> T, predicate: (T) -> Boolean): ArrayList { - val result = ArrayList() - - moveToPosition(-1) - while (moveToNext()) { - val item = map(this) - - if (!predicate(item)) break - - result.add(item) - } - - return result -} - /** * We're using this simple implementation with .range() because of the * complexities of dealing with Backpressure with a Cursor. We can't simply @@ -72,18 +55,14 @@ fun Cursor.asFlowable(): Flowable { .doOnComplete { close() } } -fun Cursor.asMaybe(): Maybe { - val subject = MaybeSubject.create() +/** + * Dumps the contents of the cursor as a CSV string + */ +fun Cursor.dump(): String { + val lines = mutableListOf() - if (moveToFirst()) { - subject.onSuccess(this) - } else { - subject.onError(IndexOutOfBoundsException("The cursor has no items")) - } + lines += columnNames.joinToString(",") + forEach { lines += (0 until columnCount).joinToString(",", transform = ::getString) } - subject.doOnComplete { close() } - return subject + return lines.joinToString("\n") } - - - diff --git a/data/src/main/java/com/moez/QKSMS/filter/ContactFilter.kt b/data/src/main/java/com/moez/QKSMS/filter/ContactFilter.kt index 8e77da1af0a69fd29e7ab1f3d1af28b81fe3377f..6010c7b305cda8b77e7c458b8e504fa27cf7831a 100644 --- a/data/src/main/java/com/moez/QKSMS/filter/ContactFilter.kt +++ b/data/src/main/java/com/moez/QKSMS/filter/ContactFilter.kt @@ -29,4 +29,4 @@ class ContactFilter @Inject constructor(private val phoneNumberFilter: PhoneNumb item.numbers.map { it.address }.any { address -> phoneNumberFilter.filter(address, query) } // Number } -} \ No newline at end of file +} diff --git a/data/src/main/java/com/moez/QKSMS/filter/ContactGroupFilter.kt b/data/src/main/java/com/moez/QKSMS/filter/ContactGroupFilter.kt new file mode 100644 index 0000000000000000000000000000000000000000..903e16fd8f989d4c3846b1efa23c8b889fa25047 --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/filter/ContactGroupFilter.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.filter + +import com.moez.QKSMS.extensions.removeAccents +import com.moez.QKSMS.model.ContactGroup +import javax.inject.Inject + +class ContactGroupFilter @Inject constructor(private val contactFilter: ContactFilter) : Filter() { + + override fun filter(item: ContactGroup, query: CharSequence): Boolean { + return item.title.removeAccents().contains(query, true) || // Name + item.contacts.any { contact -> contactFilter.filter(contact, query) } // Contacts + } + +} diff --git a/data/src/main/java/com/moez/QKSMS/listener/ContactAddedListenerImpl.kt b/data/src/main/java/com/moez/QKSMS/listener/ContactAddedListenerImpl.kt index 605b623c9e7158ef0e81111c3c4a2a64c291f0d0..1d1f74339c0c86f8f68ce0b1b03f09ae0732db61 100644 --- a/data/src/main/java/com/moez/QKSMS/listener/ContactAddedListenerImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/listener/ContactAddedListenerImpl.kt @@ -23,33 +23,28 @@ import android.database.ContentObserver import android.net.Uri import android.os.Handler import android.provider.ContactsContract -import com.moez.QKSMS.repository.SyncRepository import io.reactivex.Observable -import io.reactivex.subjects.PublishSubject +import io.reactivex.subjects.BehaviorSubject import javax.inject.Inject /** * Listens for a contact being added, and then syncs it to Realm - * - * TODO: Stop listening automatically. Currently, this will only happen if the contact is added */ class ContactAddedListenerImpl @Inject constructor( - private val context: Context, - private val syncRepo: SyncRepository + private val context: Context ) : ContactAddedListener { companion object { private val URI = ContactsContract.CommonDataKinds.Phone.CONTENT_URI } - override fun listen(address: String): Observable<*> { + override fun listen(): Observable<*> { return ContactContentObserver(context).observable - .filter { syncRepo.syncContact(address) } } private class ContactContentObserver(context: Context) : ContentObserver(Handler()) { - private val subject = PublishSubject.create() + private val subject = BehaviorSubject.createDefault(Unit) val observable: Observable = subject .doOnSubscribe { context.contentResolver.registerContentObserver(URI, true, this) } @@ -66,4 +61,4 @@ class ContactAddedListenerImpl @Inject constructor( } -} \ No newline at end of file +} diff --git a/data/src/main/java/com/moez/QKSMS/manager/ActiveConversationManagerImpl.kt b/data/src/main/java/com/moez/QKSMS/manager/ActiveConversationManagerImpl.kt index 65892a0ded42453dbb6b57d95460d352a9ca25b0..4ef8276506a0a1b8d917b72a0e5df1208ac7a135 100644 --- a/data/src/main/java/com/moez/QKSMS/manager/ActiveConversationManagerImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/manager/ActiveConversationManagerImpl.kt @@ -34,4 +34,4 @@ class ActiveConversationManagerImpl @Inject constructor() : ActiveConversationMa return threadId } -} \ No newline at end of file +} diff --git a/data/src/main/java/com/moez/QKSMS/manager/ReferralManagerImpl.kt b/data/src/main/java/com/moez/QKSMS/manager/ReferralManagerImpl.kt new file mode 100644 index 0000000000000000000000000000000000000000..4446d6740f9461e243ce118aac2faaeebee3caa8 --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/manager/ReferralManagerImpl.kt @@ -0,0 +1,57 @@ +package com.moez.QKSMS.manager + +import android.content.Context +import com.android.installreferrer.api.InstallReferrerClient +import com.android.installreferrer.api.InstallReferrerStateListener +import com.moez.QKSMS.util.Preferences +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import kotlin.coroutines.resume + +class ReferralManagerImpl @Inject constructor( + private val analytics: AnalyticsManager, + private val context: Context, + private val prefs: Preferences +) : ReferralManager { + + override suspend fun trackReferrer() { + if (prefs.didSetReferrer.get()) { + return + } + + context.packageManager.getInstallerPackageName(context.packageName)?.let { installer -> + analytics.setUserProperty("Installer", installer) + } + + val referrerClient = InstallReferrerClient.newBuilder(context).build() + val responseCode = suspendCancellableCoroutine { cont -> + referrerClient.startConnection(object : InstallReferrerStateListener { + override fun onInstallReferrerSetupFinished(responseCode: Int) { + cont.resume(responseCode) + } + + override fun onInstallReferrerServiceDisconnected() { + cont.resume(InstallReferrerClient.InstallReferrerResponse.SERVICE_DISCONNECTED) + } + }) + + cont.invokeOnCancellation { + referrerClient.endConnection() + } + } + + when (responseCode) { + InstallReferrerClient.InstallReferrerResponse.OK -> { + analytics.setUserProperty("Referrer", referrerClient.installReferrer.installReferrer) + prefs.didSetReferrer.set(true) + } + + InstallReferrerClient.InstallReferrerResponse.FEATURE_NOT_SUPPORTED -> { + prefs.didSetReferrer.set(true) + } + } + + referrerClient.endConnection() + } + +} diff --git a/data/src/main/java/com/moez/QKSMS/mapper/CursorToContactGroupImpl.kt b/data/src/main/java/com/moez/QKSMS/mapper/CursorToContactGroupImpl.kt new file mode 100644 index 0000000000000000000000000000000000000000..ad9d6532c8419f28f3863da10ee15305eab479b8 --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/mapper/CursorToContactGroupImpl.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.mapper + +import android.content.Context +import android.database.Cursor +import android.provider.ContactsContract +import com.moez.QKSMS.model.ContactGroup +import javax.inject.Inject + +class CursorToContactGroupImpl @Inject constructor( + private val context: Context +) : CursorToContactGroup { + + companion object { + private val URI = ContactsContract.Groups.CONTENT_URI + private val PROJECTION = arrayOf( + ContactsContract.Groups._ID, + ContactsContract.Groups.TITLE) + private const val SELECTION = "${ContactsContract.Groups.AUTO_ADD}=0 " + + "AND ${ContactsContract.Groups.DELETED}=0 " + + "AND ${ContactsContract.Groups.FAVORITES}=0 " + + "AND ${ContactsContract.Groups.TITLE} IS NOT NULL" + + private const val ID = 0 + private const val TITLE = 1 + } + + override fun map(from: Cursor): ContactGroup { + return ContactGroup(from.getLong(ID), from.getString(TITLE)) + } + + override fun getContactGroupsCursor(): Cursor? { + return context.contentResolver.query(URI, PROJECTION, SELECTION, null, null) + } + +} diff --git a/data/src/main/java/com/moez/QKSMS/mapper/CursorToContactGroupMemberImpl.kt b/data/src/main/java/com/moez/QKSMS/mapper/CursorToContactGroupMemberImpl.kt new file mode 100644 index 0000000000000000000000000000000000000000..7eb72aab71724b5bee23a64e33b586ebd5569ed8 --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/mapper/CursorToContactGroupMemberImpl.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.mapper + +import android.content.Context +import android.database.Cursor +import android.provider.ContactsContract +import javax.inject.Inject + +class CursorToContactGroupMemberImpl @Inject constructor( + private val context: Context +) : CursorToContactGroupMember { + + companion object { + private val URI = ContactsContract.Data.CONTENT_URI + private val PROJECTION = arrayOf( + ContactsContract.Data.LOOKUP_KEY, + ContactsContract.Data.DATA1) + + private const val SELECTION = "${ContactsContract.Data.MIMETYPE}=?" + private val SELECTION_ARGS = arrayOf( + ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE) + + private const val LOOKUP_KEY = 0 + private const val GROUP_ID = 1 + } + + override fun map(from: Cursor): CursorToContactGroupMember.GroupMember { + return CursorToContactGroupMember.GroupMember(from.getString(LOOKUP_KEY), from.getLong(GROUP_ID)) + } + + override fun getGroupMembersCursor(): Cursor? { + return context.contentResolver.query(URI, PROJECTION, SELECTION, SELECTION_ARGS, null) + } + +} diff --git a/data/src/main/java/com/moez/QKSMS/mapper/CursorToContactImpl.kt b/data/src/main/java/com/moez/QKSMS/mapper/CursorToContactImpl.kt index 82d50ca9be278d6a16b210227ea657833b5d4f35..5b618d03216ffe563873b33933b6f6afc1bda446 100644 --- a/data/src/main/java/com/moez/QKSMS/mapper/CursorToContactImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/mapper/CursorToContactImpl.kt @@ -34,30 +34,42 @@ class CursorToContactImpl @Inject constructor( companion object { val URI = Phone.CONTENT_URI val PROJECTION = arrayOf( + Phone._ID, Phone.LOOKUP_KEY, + Phone.ACCOUNT_TYPE_AND_DATA_SET, Phone.NUMBER, Phone.TYPE, Phone.LABEL, Phone.DISPLAY_NAME, + Phone.PHOTO_URI, + Phone.STARRED, Phone.CONTACT_LAST_UPDATED_TIMESTAMP ) - const val COLUMN_LOOKUP_KEY = 0 - const val COLUMN_NUMBER = 1 - const val COLUMN_TYPE = 2 - const val COLUMN_LABEL = 3 - const val COLUMN_DISPLAY_NAME = 4 - const val CONTACT_LAST_UPDATED = 5 + const val COLUMN_ID = 0 + const val COLUMN_LOOKUP_KEY = 1 + const val COLUMN_ACCOUNT_TYPE = 2 + const val COLUMN_NUMBER = 3 + const val COLUMN_TYPE = 4 + const val COLUMN_LABEL = 5 + const val COLUMN_DISPLAY_NAME = 6 + const val COLUMN_PHOTO_URI = 7 + const val COLUMN_STARRED = 8 + const val CONTACT_LAST_UPDATED = 9 } override fun map(from: Cursor) = Contact().apply { lookupKey = from.getString(COLUMN_LOOKUP_KEY) name = from.getString(COLUMN_DISPLAY_NAME) ?: "" + photoUri = from.getString(COLUMN_PHOTO_URI) numbers.add(PhoneNumber( + id = from.getLong(COLUMN_ID), + accountType = from.getString(COLUMN_ACCOUNT_TYPE), address = from.getString(COLUMN_NUMBER) ?: "", type = Phone.getTypeLabel(context.resources, from.getInt(COLUMN_TYPE), from.getString(COLUMN_LABEL)).toString() )) + starred = from.getInt(COLUMN_STARRED) != 0 lastUpdate = from.getLong(CONTACT_LAST_UPDATED) } @@ -68,4 +80,4 @@ class CursorToContactImpl @Inject constructor( } } -} \ No newline at end of file +} diff --git a/data/src/main/java/com/moez/QKSMS/mapper/CursorToRecipientImpl.kt b/data/src/main/java/com/moez/QKSMS/mapper/CursorToRecipientImpl.kt index 074828909e375f2af4079ac8e340882fad1f1cfd..08bf3974ce421fd2ca91357586de095ccadbafc7 100644 --- a/data/src/main/java/com/moez/QKSMS/mapper/CursorToRecipientImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/mapper/CursorToRecipientImpl.kt @@ -37,11 +37,10 @@ class CursorToRecipientImpl @Inject constructor( const val COLUMN_ADDRESS = 1 } - override fun map(from: Cursor) = Recipient().apply { - id = from.getLong(COLUMN_ID) - address = from.getString(COLUMN_ADDRESS) - lastUpdate = System.currentTimeMillis() - } + override fun map(from: Cursor) = Recipient( + id = from.getLong(COLUMN_ID), + address = from.getString(COLUMN_ADDRESS), + lastUpdate = System.currentTimeMillis()) override fun getRecipientCursor(): Cursor? { return when (permissionManager.hasReadSms()) { diff --git a/data/src/main/java/com/moez/QKSMS/migration/QkMigration.kt b/data/src/main/java/com/moez/QKSMS/migration/QkMigration.kt index 32e192d389c1000366ae2627605d8f3e7a036374..beafeca1f9de4c216da22f567a26afdabd9be9aa 100644 --- a/data/src/main/java/com/moez/QKSMS/migration/QkMigration.kt +++ b/data/src/main/java/com/moez/QKSMS/migration/QkMigration.kt @@ -28,13 +28,13 @@ import kotlinx.coroutines.launch import javax.inject.Inject class QkMigration @Inject constructor( - context: Context, + private val context: Context, private val conversationRepo: ConversationRepository, private val prefs: Preferences, private val qksmsBlockingClient: QksmsBlockingClient ) { - init { + fun performMigration() { GlobalScope.launch { val oldVersion = prefs.version.get() diff --git a/data/src/main/java/com/moez/QKSMS/migration/QkRealmMigration.kt b/data/src/main/java/com/moez/QKSMS/migration/QkRealmMigration.kt index 0d5ea8178a34acf1c22f2beb3e0beec48574cc89..74f3ad66dc949f05a1fd3490f98ebcfa7127085f 100644 --- a/data/src/main/java/com/moez/QKSMS/migration/QkRealmMigration.kt +++ b/data/src/main/java/com/moez/QKSMS/migration/QkRealmMigration.kt @@ -18,17 +18,28 @@ */ package com.moez.QKSMS.migration +import android.annotation.SuppressLint +import com.moez.QKSMS.extensions.map +import com.moez.QKSMS.mapper.CursorToContactImpl +import com.moez.QKSMS.util.Preferences import io.realm.DynamicRealm +import io.realm.DynamicRealmObject import io.realm.FieldAttribute +import io.realm.RealmList import io.realm.RealmMigration import io.realm.Sort +import javax.inject.Inject -class QkRealmMigration : RealmMigration { +class QkRealmMigration @Inject constructor( + private val cursorToContact: CursorToContactImpl, + private val prefs: Preferences +) : RealmMigration { companion object { - const val SCHEMA_VERSION: Long = 8 + const val SchemaVersion: Long = 9 } + @SuppressLint("ApplySharedPref") override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { var version = oldVersion @@ -118,6 +129,70 @@ class QkRealmMigration : RealmMigration { version++ } + if (version == 8L) { + // Delete this data since we'll need to repopulate it with its new primaryKey + realm.delete("PhoneNumber") + + realm.schema.create("ContactGroup") + .addField("id", Long::class.java, FieldAttribute.PRIMARY_KEY, FieldAttribute.REQUIRED) + .addField("title", String::class.java, FieldAttribute.REQUIRED) + .addRealmListField("contacts", realm.schema.get("Contact")) + + realm.schema.get("PhoneNumber") + ?.addField("id", Long::class.java, FieldAttribute.PRIMARY_KEY, FieldAttribute.REQUIRED) + ?.addField("accountType", String::class.java) + ?.addField("isDefault", Boolean::class.java, FieldAttribute.REQUIRED) + + val phoneNumbers = cursorToContact.getContactsCursor() + ?.map(cursorToContact::map) + ?.distinctBy { contact -> contact.numbers.firstOrNull()?.id } // Each row has only one number + ?.groupBy { contact -> contact.lookupKey } + ?: mapOf() + + realm.schema.get("Contact") + ?.addField("starred", Boolean::class.java, FieldAttribute.REQUIRED) + ?.addField("photoUri", String::class.java) + ?.transform { realmContact -> + val numbers = RealmList() + phoneNumbers[realmContact.get("lookupKey")] + ?.flatMap { contact -> contact.numbers } + ?.map { number -> + realm.createObject("PhoneNumber", number.id).apply { + setString("accountType", number.accountType) + setString("address", number.address) + setString("type", number.type) + } + } + ?.let(numbers::addAll) + + val photoUri = phoneNumbers[realmContact.get("lookupKey")] + ?.firstOrNull { number -> number.photoUri != null } + ?.photoUri + + realmContact.setList("numbers", numbers) + realmContact.setString("photoUri", photoUri) + } + + // Migrate conversation themes + val recipients = mutableMapOf() // Map of recipientId:theme + realm.where("Conversation").findAll().forEach { conversation -> + val pref = prefs.theme(conversation.getLong("id")) + if (pref.isSet) { + conversation.getList("recipients").forEach { recipient -> + recipients[recipient.getLong("id")] = pref.get() + } + + pref.delete() + } + } + + recipients.forEach { (recipientId, theme) -> + prefs.theme(recipientId).set(theme) + } + + version++ + } + check(version >= newVersion) { "Migration missing from v$oldVersion to v$newVersion" } } diff --git a/data/src/main/java/com/moez/QKSMS/repository/ContactRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/ContactRepositoryImpl.kt index 848e7d38a9f44b0f942f08b6d98a6401c163ea25..ee3d25fa23269d992fd6791e7db70c7dddab3d00 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/ContactRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/ContactRepositoryImpl.kt @@ -25,10 +25,13 @@ import android.provider.ContactsContract import android.provider.ContactsContract.CommonDataKinds.Email import android.provider.ContactsContract.CommonDataKinds.Phone import com.moez.QKSMS.extensions.asFlowable +import com.moez.QKSMS.extensions.asObservable import com.moez.QKSMS.extensions.mapNotNull import com.moez.QKSMS.model.Contact +import com.moez.QKSMS.model.ContactGroup import com.moez.QKSMS.util.Preferences import io.reactivex.Flowable +import io.reactivex.Observable import io.reactivex.Single import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers @@ -70,43 +73,93 @@ class ContactRepositoryImpl @Inject constructor( .findAll() } - override fun getUnmanagedContacts(): Flowable> { + override fun getUnmanagedContact(lookupKey: String): Contact? { + return Realm.getDefaultInstance().use { realm -> + realm.where(Contact::class.java) + .equalTo("lookupKey", lookupKey) + .findFirst() + ?.let(realm::copyFromRealm) + } + } + + override fun getUnmanagedContacts(starred: Boolean): Observable> { val realm = Realm.getDefaultInstance() - val mobileLabel by lazy { - Phone.getTypeLabel(context.resources, Phone.TYPE_MOBILE, "Mobile").toString() + val mobileOnly = prefs.mobileOnly.get() + val mobileLabel by lazy { Phone.getTypeLabel(context.resources, Phone.TYPE_MOBILE, "Mobile").toString() } + + var query = realm.where(Contact::class.java) + + if (mobileOnly) { + query = query.contains("numbers.type", mobileLabel) + } + + if (starred) { + query = query.equalTo("starred", true) } - return when (prefs.mobileOnly.get()) { - true -> realm.where(Contact::class.java) - .contains("numbers.type", mobileLabel) - .sort("name") - .findAllAsync() - .asFlowable() - .filter { it.isLoaded } - .filter { it.isValid } - .map { realm.copyFromRealm(it) } - .subscribeOn(AndroidSchedulers.mainThread()) - .observeOn(Schedulers.io()) - .map { contacts -> + return query + .findAllAsync() + .asObservable() + .filter { it.isLoaded } + .filter { it.isValid } + .map { realm.copyFromRealm(it) } + .subscribeOn(AndroidSchedulers.mainThread()) + .observeOn(Schedulers.io()) + .map { contacts -> + if (mobileOnly) { contacts.map { contact -> val filteredNumbers = contact.numbers.filter { number -> number.type == mobileLabel } contact.numbers.clear() contact.numbers.addAll(filteredNumbers) contact } + } else { + contacts } + } + .map { contacts -> + contacts.sortedWith(Comparator { c1, c2 -> + val initial = c1.name.firstOrNull() + val other = c2.name.firstOrNull() + if (initial?.isLetter() == true && other?.isLetter() != true) { + -1 + } else if (initial?.isLetter() != true && other?.isLetter() == true) { + 1 + } else { + c1.name.compareTo(c2.name, ignoreCase = true) + } + }) + } + } + + override fun getUnmanagedContactGroups(): Observable> { + val realm = Realm.getDefaultInstance() + return realm.where(ContactGroup::class.java) + .isNotEmpty("contacts") + .findAllAsync() + .asObservable() + .filter { it.isLoaded } + .filter { it.isValid } + .map { realm.copyFromRealm(it) } + .subscribeOn(AndroidSchedulers.mainThread()) + .observeOn(Schedulers.io()) + } - false -> realm.where(Contact::class.java) - .sort("name") - .findAllAsync() - .asFlowable() - .filter { it.isLoaded } - .filter { it.isValid } - .map { realm.copyFromRealm(it) } - .subscribeOn(AndroidSchedulers.mainThread()) - .observeOn(Schedulers.io()) + override fun setDefaultPhoneNumber(lookupKey: String, phoneNumberId: Long) { + Realm.getDefaultInstance().use { realm -> + realm.refresh() + val contact = realm.where(Contact::class.java) + .equalTo("lookupKey", lookupKey) + .findFirst() + ?: return + + realm.executeTransaction { + contact.numbers.forEach { number -> + number.isDefault = number.id == phoneNumberId + } + } } } -} \ No newline at end of file +} diff --git a/data/src/main/java/com/moez/QKSMS/repository/ConversationRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/ConversationRepositoryImpl.kt index 0c05e22279bb0b95dc998bd9ff7a1190441cc1d4..7706c904aff94c77f54900d03e77288add70297a 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/ConversationRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/ConversationRepositoryImpl.kt @@ -23,6 +23,7 @@ import android.content.Context import android.provider.Telephony import com.moez.QKSMS.compat.TelephonyCompat import com.moez.QKSMS.extensions.anyOf +import com.moez.QKSMS.extensions.asObservable import com.moez.QKSMS.extensions.map import com.moez.QKSMS.extensions.removeAccents import com.moez.QKSMS.filter.ConversationFilter @@ -35,6 +36,9 @@ import com.moez.QKSMS.model.Recipient import com.moez.QKSMS.model.SearchResult import com.moez.QKSMS.util.PhoneNumberUtils import com.moez.QKSMS.util.tryOrNull +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers import io.realm.Case import io.realm.Realm import io.realm.RealmResults @@ -54,11 +58,18 @@ class ConversationRepositoryImpl @Inject constructor( return Realm.getDefaultInstance() .where(Conversation::class.java) .notEqualTo("id", 0L) - .isNotNull("lastMessage") .equalTo("archived", archived) .equalTo("blocked", false) .isNotEmpty("recipients") - .sort("pinned", Sort.DESCENDING, "lastMessage.date", Sort.DESCENDING) + .beginGroup() + .isNotNull("lastMessage") + .or() + .isNotEmpty("draft") + .endGroup() + .sort( + arrayOf("pinned", "draft", "lastMessage.date"), + arrayOf(Sort.DESCENDING, Sort.DESCENDING, Sort.DESCENDING) + ) .findAllAsync() } @@ -67,11 +78,18 @@ class ConversationRepositoryImpl @Inject constructor( realm.refresh() realm.copyFromRealm(realm.where(Conversation::class.java) .notEqualTo("id", 0L) - .isNotNull("lastMessage") .equalTo("archived", false) .equalTo("blocked", false) .isNotEmpty("recipients") - .sort("pinned", Sort.DESCENDING, "lastMessage.date", Sort.DESCENDING) + .beginGroup() + .isNotNull("lastMessage") + .or() + .isNotEmpty("draft") + .endGroup() + .sort( + arrayOf("pinned", "draft", "lastMessage.date"), + arrayOf(Sort.DESCENDING, Sort.DESCENDING, Sort.DESCENDING) + ) .findAll()) } } @@ -183,6 +201,42 @@ class ConversationRepositoryImpl @Inject constructor( .findAll() } + override fun getUnmanagedConversations(): Observable> { + val realm = Realm.getDefaultInstance() + return realm.where(Conversation::class.java) + .sort("lastMessage.date", Sort.DESCENDING) + .notEqualTo("id", 0L) + .isNotNull("lastMessage") + .equalTo("archived", false) + .equalTo("blocked", false) + .isNotEmpty("recipients") + .limit(5) + .findAllAsync() + .asObservable() + .filter { it.isLoaded } + .filter { it.isValid } + .map { realm.copyFromRealm(it) } + .subscribeOn(AndroidSchedulers.mainThread()) + .observeOn(Schedulers.io()) + } + + override fun getRecipients(): RealmResults { + val realm = Realm.getDefaultInstance() + return realm.where(Recipient::class.java) + .findAll() + } + + override fun getUnmanagedRecipients(): Observable> { + val realm = Realm.getDefaultInstance() + return realm.where(Recipient::class.java) + .isNotNull("contact") + .findAllAsync() + .asObservable() + .filter { it.isLoaded && it.isValid } + .map { realm.copyFromRealm(it) } + .subscribeOn(AndroidSchedulers.mainThread()) + } + override fun getRecipient(recipientId: Long): Recipient? { return Realm.getDefaultInstance() .where(Recipient::class.java) diff --git a/data/src/main/java/com/moez/QKSMS/repository/ImageRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/ImageRepositoryImpl.kt deleted file mode 100644 index a4aee36426d376926120fa21348fed1b78901fee..0000000000000000000000000000000000000000 --- a/data/src/main/java/com/moez/QKSMS/repository/ImageRepositoryImpl.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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 com.moez.QKSMS.repository - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Matrix -import android.net.Uri -import androidx.exifinterface.media.ExifInterface -import javax.inject.Inject - -class ImageRepositoryImpl @Inject constructor(private val context: Context) : ImageRepository { - - override fun loadImage(uri: Uri, width: Int, height: Int): Bitmap? { - val orientation = context.contentResolver.openInputStream(uri)?.use(::ExifInterface) - ?.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) - val rotated = orientation == ExifInterface.ORIENTATION_ROTATE_90 - || orientation == ExifInterface.ORIENTATION_ROTATE_270 - - // Determine the dimensions - val dimensionsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeStream(context.contentResolver.openInputStream(uri), null, dimensionsOptions) - val srcWidth = if (rotated) dimensionsOptions.outHeight else dimensionsOptions.outWidth - val srcHeight = if (rotated) dimensionsOptions.outWidth else dimensionsOptions.outHeight - - // If we get the dimensions and they don't exceed the max size, we don't need to scale - val inputStream = context.contentResolver.openInputStream(uri) - val bitmap = if ((width == 0 || srcWidth < width) && (height == 0 || srcHeight < height)) { - BitmapFactory.decodeStream(inputStream) - } else { - val widthScaleFactor = width.toDouble() / srcWidth - val heightScaleFactor = height.toDouble() / srcHeight - val options = when { - widthScaleFactor > heightScaleFactor -> BitmapFactory.Options().apply { - inScaled = true - inSampleSize = 4 - inDensity = srcHeight - inTargetDensity = height * inSampleSize - } - - else -> BitmapFactory.Options().apply { - inScaled = true - inSampleSize = 4 - inDensity = srcWidth - inTargetDensity = width * inSampleSize - } - } - BitmapFactory.decodeStream(inputStream, null, options) ?: return null - } - - return when (orientation) { - ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90f) - ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180f) - ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270f) - else -> bitmap - } - } - - private fun rotateBitmap(bitmap: Bitmap, degree: Float): Bitmap { - val w = bitmap.width - val h = bitmap.height - - val mtx = Matrix() - mtx.postRotate(degree) - - return Bitmap.createBitmap(bitmap, 0, 0, w, h, mtx, true) - } - -} diff --git a/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt index 9ec414cea5455b1e1a2c3cf1f37a765fc3215140..1eb62d2345dcdf93117f3518d0d6b6ab172093c6 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/MessageRepositoryImpl.kt @@ -24,10 +24,13 @@ import android.content.ContentUris import android.content.ContentValues import android.content.Context import android.content.Intent +import android.graphics.BitmapFactory import android.media.MediaScannerConnection import android.os.Build import android.os.Environment import android.provider.Telephony +import android.provider.Telephony.Mms +import android.provider.Telephony.Sms import android.telephony.SmsManager import android.webkit.MimeTypeMap import androidx.core.content.contentValuesOf @@ -38,6 +41,7 @@ import com.google.android.mms.pdu_alt.PduPersister import com.klinker.android.send_message.SmsManagerFactory import com.klinker.android.send_message.StripAccents import com.klinker.android.send_message.Transaction +import com.moez.QKSMS.common.util.extensions.now import com.moez.QKSMS.compat.TelephonyCompat import com.moez.QKSMS.extensions.anyOf import com.moez.QKSMS.manager.ActiveConversationManager @@ -64,12 +68,12 @@ import java.io.FileOutputStream import java.io.IOException import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.sqrt @Singleton class MessageRepositoryImpl @Inject constructor( private val activeConversationManager: ActiveConversationManager, private val context: Context, - private val imageRepository: ImageRepository, private val messageIds: KeyManager, private val phoneNumberUtils: PhoneNumberUtils, private val prefs: Preferences, @@ -110,6 +114,25 @@ class MessageRepositoryImpl @Inject constructor( .findFirst() } + override fun getLastIncomingMessage(threadId: Long): RealmResults { + return Realm.getDefaultInstance() + .where(Message::class.java) + .equalTo("threadId", threadId) + .beginGroup() + .beginGroup() + .equalTo("type", "sms") + .`in`("boxId", arrayOf(Sms.MESSAGE_TYPE_INBOX, Sms.MESSAGE_TYPE_ALL)) + .endGroup() + .or() + .beginGroup() + .equalTo("type", "mms") + .`in`("boxId", arrayOf(Mms.MESSAGE_BOX_INBOX, Mms.MESSAGE_BOX_ALL)) + .endGroup() + .endGroup() + .sort("date", Sort.DESCENDING) + .findAll() + } + override fun getUnreadCount(): Long { return Realm.getDefaultInstance().use { realm -> realm.refresh() @@ -239,13 +262,13 @@ class MessageRepositoryImpl @Inject constructor( } val values = ContentValues() - values.put(Telephony.Sms.SEEN, true) - values.put(Telephony.Sms.READ, true) + values.put(Sms.SEEN, true) + values.put(Sms.READ, true) threadIds.forEach { threadId -> try { val uri = ContentUris.withAppendedId(Telephony.MmsSms.CONTENT_CONVERSATIONS_URI, threadId) - context.contentResolver.update(uri, values, "${Telephony.Sms.READ} = 0", null) + context.contentResolver.update(uri, values, "${Sms.READ} = 0", null) } catch (exception: Exception) { Timber.w(exception) } @@ -281,10 +304,23 @@ class MessageRepositoryImpl @Inject constructor( else -> prefs.signature.get() } - if (addresses.size == 1 && attachments.isEmpty()) { // SMS + val smsManager = subId.takeIf { it != -1 } + ?.let(SmsManagerFactory::createSmsManager) + ?: SmsManager.getDefault() + + // We only care about stripping SMS + val strippedBody = when (prefs.unicode.get()) { + true -> StripAccents.stripAccents(signedBody) + false -> signedBody + } + + val parts = smsManager.divideMessage(strippedBody).orEmpty() + val forceMms = prefs.longAsMms.get() && parts.size > 1 + + if (addresses.size == 1 && attachments.isEmpty() && !forceMms) { // SMS if (delay > 0) { // With delay val sendTime = System.currentTimeMillis() + delay - val message = insertSentSms(subId, threadId, addresses.first(), signedBody, sendTime) + val message = insertSentSms(subId, threadId, addresses.first(), strippedBody, sendTime) val intent = getIntentForDelayedSms(message.id) @@ -295,49 +331,98 @@ class MessageRepositoryImpl @Inject constructor( alarmManager.setExact(AlarmManager.RTC_WAKEUP, sendTime, intent) } } else { // No delay - val message = insertSentSms(subId, threadId, addresses.first(), signedBody, System.currentTimeMillis()) + val message = insertSentSms(subId, threadId, addresses.first(), strippedBody, now()) sendSms(message) } } else { // MMS val parts = arrayListOf() - if (signedBody.isNotBlank()) { - parts += MMSPart("text", ContentType.TEXT_PLAIN, signedBody.toByteArray()) - } + val maxWidth = smsManager.carrierConfigValues.getInt(SmsManager.MMS_CONFIG_MAX_IMAGE_WIDTH) + .takeIf { prefs.mmsSize.get() == -1 } ?: Int.MAX_VALUE - val smsManager = subId.takeIf { it != -1 } - ?.let(SmsManagerFactory::createSmsManager) - ?: SmsManager.getDefault() - val width = smsManager.carrierConfigValues.getInt(SmsManager.MMS_CONFIG_MAX_IMAGE_WIDTH) - val height = smsManager.carrierConfigValues.getInt(SmsManager.MMS_CONFIG_MAX_IMAGE_HEIGHT) + val maxHeight = smsManager.carrierConfigValues.getInt(SmsManager.MMS_CONFIG_MAX_IMAGE_HEIGHT) + .takeIf { prefs.mmsSize.get() == -1 } ?: Int.MAX_VALUE - // Add the GIFs as attachments - parts += attachments - .mapNotNull { attachment -> attachment as? Attachment.Image } - .filter { attachment -> attachment.isGif(context) } - .mapNotNull { attachment -> attachment.getUri() } - .map { uri -> ImageUtils.compressGif(context, uri, prefs.mmsSize.get() * 1024) } - .map { bitmap -> MMSPart("image", ContentType.IMAGE_GIF, bitmap) } + var remainingBytes = when (prefs.mmsSize.get()) { + -1 -> smsManager.carrierConfigValues.getInt(SmsManager.MMS_CONFIG_MAX_MESSAGE_SIZE) + 0 -> Int.MAX_VALUE + else -> prefs.mmsSize.get() * 1024 + } * 0.9 // Ugly, but buys us a bit of wiggle room - // Compress the images and add them as attachments - var totalImageBytes = 0 - parts += attachments - .mapNotNull { attachment -> attachment as? Attachment.Image } - .filter { attachment -> !attachment.isGif(context) } - .mapNotNull { attachment -> attachment.getUri() } - .mapNotNull { uri -> tryOrNull { imageRepository.loadImage(uri, width, height) } } - .also { totalImageBytes = it.sumBy { it.allocationByteCount } } - .map { bitmap -> - val byteRatio = bitmap.allocationByteCount / totalImageBytes.toFloat() - ImageUtils.compressBitmap(bitmap, (prefs.mmsSize.get() * 1024 * byteRatio).toInt()) - } - .map { bitmap -> MMSPart("image", ContentType.IMAGE_JPEG, bitmap) } + signedBody.takeIf { it.isNotEmpty() }?.toByteArray()?.let { bytes -> + remainingBytes -= bytes.size + parts += MMSPart("text", ContentType.TEXT_PLAIN, bytes) + } - // Send contacts + // Attach contacts parts += attachments .mapNotNull { attachment -> attachment as? Attachment.Contact } .map { attachment -> attachment.vCard.toByteArray() } - .map { vCard -> MMSPart("contact", ContentType.TEXT_VCARD, vCard) } + .map { vCard -> + remainingBytes -= vCard.size + MMSPart("contact", ContentType.TEXT_VCARD, vCard) + } + + val imageBytesByAttachment = attachments + .mapNotNull { attachment -> attachment as? Attachment.Image } + .associateWith { attachment -> + val uri = attachment.getUri() ?: return@associateWith byteArrayOf() + when (attachment.isGif(context)) { + true -> ImageUtils.getScaledGif(context, uri, maxWidth, maxHeight) + false -> ImageUtils.getScaledImage(context, uri, maxWidth, maxHeight) + } + } + .toMutableMap() + + val imageByteCount = imageBytesByAttachment.values.sumBy { byteArray -> byteArray.size } + if (imageByteCount > remainingBytes) { + imageBytesByAttachment.forEach { (attachment, originalBytes) -> + val uri = attachment.getUri() ?: return@forEach + val maxBytes = originalBytes.size / imageByteCount.toFloat() * remainingBytes + + // Get the image dimensions + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeStream(context.contentResolver.openInputStream(uri), null, options) + val width = options.outWidth + val height = options.outHeight + val aspectRatio = width.toFloat() / height.toFloat() + + var attempts = 0 + var scaledBytes = originalBytes + + while (scaledBytes.size > maxBytes) { + // Estimate how much we need to scale the image down by. If it's still too big, we'll need to + // try smaller and smaller values + val scale = maxBytes / originalBytes.size * (0.9 - attempts * 0.2) + if (scale <= 0) { + Timber.w("Failed to compress ${originalBytes.size / 1024}Kb to ${maxBytes.toInt() / 1024}Kb") + return@forEach + } + + val newArea = scale * width * height + val newWidth = sqrt(newArea * aspectRatio).toInt() + val newHeight = (newWidth / aspectRatio).toInt() + + attempts++ + scaledBytes = when (attachment.isGif(context)) { + true -> ImageUtils.getScaledGif(context, uri, newWidth, newHeight, 80) + false -> ImageUtils.getScaledImage(context, uri, newWidth, newHeight, 80) + } + + Timber.d("Compression attempt $attempts: ${scaledBytes.size / 1024}/${maxBytes.toInt() / 1024}Kb ($width*$height -> $newWidth*$newHeight)") + } + + Timber.v("Compressed ${originalBytes.size / 1024}Kb to ${scaledBytes.size / 1024}Kb with a target size of ${maxBytes.toInt() / 1024}Kb in $attempts attempts") + imageBytesByAttachment[attachment] = scaledBytes + } + } + + imageBytesByAttachment.forEach { (attachment, bytes) -> + parts += when (attachment.isGif(context)) { + true -> MMSPart("image", ContentType.IMAGE_GIF, bytes) + false -> MMSPart("image", ContentType.IMAGE_JPEG, bytes) + } + } // We need to strip the separators from outgoing MMS, or else they'll appear to have sent and not go through val transaction = Transaction(context) @@ -390,7 +475,7 @@ class MessageRepositoryImpl @Inject constructor( val addresses = pdu.to.map { it.string }.filter { it.isNotBlank() } val parts = message.parts.mapNotNull { part -> - val bytes = tryOrNull { + val bytes = tryOrNull(false) { context.contentResolver.openInputStream(part.getUri())?.use { inputStream -> inputStream.readBytes() } } ?: return@mapNotNull null @@ -421,7 +506,7 @@ class MessageRepositoryImpl @Inject constructor( this.subId = subId id = messageIds.newId() - boxId = Telephony.Sms.MESSAGE_TYPE_OUTBOX + boxId = Sms.MESSAGE_TYPE_OUTBOX type = "sms" read = true seen = true @@ -432,20 +517,20 @@ class MessageRepositoryImpl @Inject constructor( // Insert the message to the native content provider val values = contentValuesOf( - Telephony.Sms.ADDRESS to address, - Telephony.Sms.BODY to body, - Telephony.Sms.DATE to System.currentTimeMillis(), - Telephony.Sms.READ to true, - Telephony.Sms.SEEN to true, - Telephony.Sms.TYPE to Telephony.Sms.MESSAGE_TYPE_OUTBOX, - Telephony.Sms.THREAD_ID to threadId + Sms.ADDRESS to address, + Sms.BODY to body, + Sms.DATE to System.currentTimeMillis(), + Sms.READ to true, + Sms.SEEN to true, + Sms.TYPE to Sms.MESSAGE_TYPE_OUTBOX, + Sms.THREAD_ID to threadId ) if (prefs.canUseSubId.get()) { - values.put(Telephony.Sms.SUBSCRIPTION_ID, message.subId) + values.put(Sms.SUBSCRIPTION_ID, message.subId) } - val uri = context.contentResolver.insert(Telephony.Sms.CONTENT_URI, values) + val uri = context.contentResolver.insert(Sms.CONTENT_URI, values) // Update the contentId after the message has been inserted to the content provider // The message might have been deleted by now, so only proceed if it's valid @@ -479,7 +564,7 @@ class MessageRepositoryImpl @Inject constructor( id = messageIds.newId() threadId = TelephonyCompat.getOrCreateThreadId(context, address) - boxId = Telephony.Sms.MESSAGE_TYPE_INBOX + boxId = Sms.MESSAGE_TYPE_INBOX type = "sms" read = activeConversationManager.getActiveConversation() == threadId } @@ -489,16 +574,16 @@ class MessageRepositoryImpl @Inject constructor( // Insert the message to the native content provider val values = contentValuesOf( - Telephony.Sms.ADDRESS to address, - Telephony.Sms.BODY to body, - Telephony.Sms.DATE_SENT to sentTime + Sms.ADDRESS to address, + Sms.BODY to body, + Sms.DATE_SENT to sentTime ) if (prefs.canUseSubId.get()) { - values.put(Telephony.Sms.SUBSCRIPTION_ID, message.subId) + values.put(Sms.SUBSCRIPTION_ID, message.subId) } - context.contentResolver.insert(Telephony.Sms.Inbox.CONTENT_URI, values)?.lastPathSegment?.toLong()?.let { id -> + context.contentResolver.insert(Sms.Inbox.CONTENT_URI, values)?.lastPathSegment?.toLong()?.let { id -> // Update the contentId after the message has been inserted to the content provider realm.executeTransaction { managedMessage?.contentId = id } } @@ -520,15 +605,15 @@ class MessageRepositoryImpl @Inject constructor( // Update the message in realm realm.executeTransaction { message.boxId = when (message.isSms()) { - true -> Telephony.Sms.MESSAGE_TYPE_OUTBOX - false -> Telephony.Mms.MESSAGE_BOX_OUTBOX + true -> Sms.MESSAGE_TYPE_OUTBOX + false -> Mms.MESSAGE_BOX_OUTBOX } } // Update the message in the native ContentProvider val values = when (message.isSms()) { - true -> contentValuesOf(Telephony.Sms.TYPE to Telephony.Sms.MESSAGE_TYPE_OUTBOX) - false -> contentValuesOf(Telephony.Mms.MESSAGE_BOX to Telephony.Mms.MESSAGE_BOX_OUTBOX) + true -> contentValuesOf(Sms.TYPE to Sms.MESSAGE_TYPE_OUTBOX) + false -> contentValuesOf(Mms.MESSAGE_BOX to Mms.MESSAGE_BOX_OUTBOX) } context.contentResolver.update(message.getUri(), values, null, null) } @@ -543,12 +628,12 @@ class MessageRepositoryImpl @Inject constructor( message?.let { // Update the message in realm realm.executeTransaction { - message.boxId = Telephony.Sms.MESSAGE_TYPE_SENT + message.boxId = Sms.MESSAGE_TYPE_SENT } // Update the message in the native ContentProvider val values = ContentValues() - values.put(Telephony.Sms.TYPE, Telephony.Sms.MESSAGE_TYPE_SENT) + values.put(Sms.TYPE, Sms.MESSAGE_TYPE_SENT) context.contentResolver.update(message.getUri(), values, null, null) } } @@ -562,14 +647,14 @@ class MessageRepositoryImpl @Inject constructor( message?.let { // Update the message in realm realm.executeTransaction { - message.boxId = Telephony.Sms.MESSAGE_TYPE_FAILED + message.boxId = Sms.MESSAGE_TYPE_FAILED message.errorCode = resultCode } // Update the message in the native ContentProvider val values = ContentValues() - values.put(Telephony.Sms.TYPE, Telephony.Sms.MESSAGE_TYPE_FAILED) - values.put(Telephony.Sms.ERROR_CODE, resultCode) + values.put(Sms.TYPE, Sms.MESSAGE_TYPE_FAILED) + values.put(Sms.ERROR_CODE, resultCode) context.contentResolver.update(message.getUri(), values, null, null) } } @@ -583,16 +668,16 @@ class MessageRepositoryImpl @Inject constructor( message?.let { // Update the message in realm realm.executeTransaction { - message.deliveryStatus = Telephony.Sms.STATUS_COMPLETE + message.deliveryStatus = Sms.STATUS_COMPLETE message.dateSent = System.currentTimeMillis() message.read = true } // Update the message in the native ContentProvider val values = ContentValues() - values.put(Telephony.Sms.STATUS, Telephony.Sms.STATUS_COMPLETE) - values.put(Telephony.Sms.DATE_SENT, System.currentTimeMillis()) - values.put(Telephony.Sms.READ, true) + values.put(Sms.STATUS, Sms.STATUS_COMPLETE) + values.put(Sms.DATE_SENT, System.currentTimeMillis()) + values.put(Sms.READ, true) context.contentResolver.update(message.getUri(), values, null, null) } } @@ -606,7 +691,7 @@ class MessageRepositoryImpl @Inject constructor( message?.let { // Update the message in realm realm.executeTransaction { - message.deliveryStatus = Telephony.Sms.STATUS_FAILED + message.deliveryStatus = Sms.STATUS_FAILED message.dateSent = System.currentTimeMillis() message.read = true message.errorCode = resultCode @@ -614,10 +699,10 @@ class MessageRepositoryImpl @Inject constructor( // Update the message in the native ContentProvider val values = ContentValues() - values.put(Telephony.Sms.STATUS, Telephony.Sms.STATUS_FAILED) - values.put(Telephony.Sms.DATE_SENT, System.currentTimeMillis()) - values.put(Telephony.Sms.READ, true) - values.put(Telephony.Sms.ERROR_CODE, resultCode) + values.put(Sms.STATUS, Sms.STATUS_FAILED) + values.put(Sms.DATE_SENT, System.currentTimeMillis()) + values.put(Sms.READ, true) + values.put(Sms.ERROR_CODE, resultCode) context.contentResolver.update(message.getUri(), values, null, null) } } diff --git a/data/src/main/java/com/moez/QKSMS/repository/SyncRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/SyncRepositoryImpl.kt index 46bc532d8b700a94f53329944fa385d5e7d3c9ef..8cb37238bd1f9930a8160494a50789eacda23a10 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/SyncRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/SyncRepositoryImpl.kt @@ -27,13 +27,17 @@ import com.moez.QKSMS.extensions.insertOrUpdate import com.moez.QKSMS.extensions.map import com.moez.QKSMS.manager.KeyManager import com.moez.QKSMS.mapper.CursorToContact +import com.moez.QKSMS.mapper.CursorToContactGroup +import com.moez.QKSMS.mapper.CursorToContactGroupMember import com.moez.QKSMS.mapper.CursorToConversation import com.moez.QKSMS.mapper.CursorToMessage import com.moez.QKSMS.mapper.CursorToRecipient import com.moez.QKSMS.model.Contact +import com.moez.QKSMS.model.ContactGroup import com.moez.QKSMS.model.Conversation import com.moez.QKSMS.model.Message import com.moez.QKSMS.model.MmsPart +import com.moez.QKSMS.model.PhoneNumber import com.moez.QKSMS.model.Recipient import com.moez.QKSMS.model.SyncLog import com.moez.QKSMS.util.PhoneNumberUtils @@ -53,13 +57,15 @@ class SyncRepositoryImpl @Inject constructor( private val cursorToMessage: CursorToMessage, private val cursorToRecipient: CursorToRecipient, private val cursorToContact: CursorToContact, + private val cursorToContactGroup: CursorToContactGroup, + private val cursorToContactGroupMember: CursorToContactGroupMember, private val keys: KeyManager, private val phoneNumberUtils: PhoneNumberUtils, private val rxPrefs: RxSharedPreferences ) : SyncRepository { override val syncProgress: Subject = - BehaviorSubject.createDefault(SyncRepository.SyncProgress.Idle()) + BehaviorSubject.createDefault(SyncRepository.SyncProgress.Idle) override fun syncMessages() { @@ -89,6 +95,7 @@ class SyncRepositoryImpl @Inject constructor( .toMutableMap() realm.delete(Contact::class.java) + realm.delete(ContactGroup::class.java) realm.delete(Conversation::class.java) realm.delete(Message::class.java) realm.delete(MmsPart::class.java) @@ -155,7 +162,7 @@ class SyncRepositoryImpl @Inject constructor( // Sync recipients recipientCursor?.use { - val contacts = realm.copyToRealm(getContacts()) + val contacts = realm.copyToRealmOrUpdate(getContacts()) val recipients = recipientCursor.map { cursor -> progress++ syncProgress.onNext(SyncRepository.SyncProgress.Running(max, progress, false)) @@ -178,7 +185,7 @@ class SyncRepositoryImpl @Inject constructor( // Only delete this after the sync has successfully completed oldBlockedSenders.delete() - syncProgress.onNext(SyncRepository.SyncProgress.Idle()) + syncProgress.onNext(SyncRepository.SyncProgress.Idle) } override fun syncMessage(uri: Uri): Message? { @@ -234,63 +241,77 @@ class SyncRepositoryImpl @Inject constructor( realm.executeTransaction { realm.delete(Contact::class.java) + realm.delete(ContactGroup::class.java) - contacts = realm.copyToRealm(contacts) + contacts = realm.copyToRealmOrUpdate(contacts) + realm.insertOrUpdate(getContactGroups(contacts)) // Update all the recipients with the new contacts - val updatedRecipients = recipients.map { recipient -> - recipient.apply { - contact = contacts.firstOrNull { - it.numbers.any { phoneNumberUtils.compare(recipient.address, it.address) } - } + recipients.forEach { recipient -> + recipient.contact = contacts.find { contact -> + contact.numbers.any { phoneNumberUtils.compare(recipient.address, it.address) } } } - realm.insertOrUpdate(updatedRecipients) + realm.insertOrUpdate(recipients) } } } - override fun syncContact(address: String): Boolean { - // See if there's a contact that matches this phone number - var contact = getContacts().firstOrNull { - it.numbers.any { number -> phoneNumberUtils.compare(number.address, address) } - } ?: return false - - Realm.getDefaultInstance().use { realm -> - val recipients = realm.where(Recipient::class.java).findAll() - - realm.executeTransaction { - contact = realm.copyToRealmOrUpdate(contact) - - // Update all the matching recipients with the new contact - val updatedRecipients = recipients - .filter { recipient -> - contact.numbers.any { number -> - phoneNumberUtils.compare(recipient.address, number.address) - } - } - .map { recipient -> recipient.apply { this.contact = contact } } - - realm.insertOrUpdate(updatedRecipients) - } + private fun getContacts(): List { + val defaultNumberIds = Realm.getDefaultInstance().use { realm -> + realm.where(PhoneNumber::class.java) + .equalTo("isDefault", true) + .findAll() + .map { number -> number.id } } - return true - } - - private fun getContacts(): List { return cursorToContact.getContactsCursor() ?.map { cursor -> cursorToContact.map(cursor) } ?.groupBy { contact -> contact.lookupKey } ?.map { contacts -> - val allNumbers = contacts.value.map { it.numbers }.flatten() + // Sometimes, contacts providers on the phone will create duplicate phone number entries. This + // commonly happens with Whatsapp. Let's try to detect these duplicate entries and filter them out + val uniqueNumbers = mutableListOf() + contacts.value + .flatMap { it.numbers } + .sortedBy { it.accountType } + .forEach { number -> + number.isDefault = defaultNumberIds.any { id -> id == number.id } + val duplicate = uniqueNumbers.any { other -> + number.accountType != other.accountType + && phoneNumberUtils.compare(number.address, other.address) + } + + if (!duplicate) { + uniqueNumbers += number + } + } + contacts.value.first().apply { numbers.clear() - numbers.addAll(allNumbers) + numbers.addAll(uniqueNumbers) } } ?: listOf() } -} \ No newline at end of file + private fun getContactGroups(contacts: List): List { + val groupMembers = cursorToContactGroupMember.getGroupMembersCursor() + ?.map(cursorToContactGroupMember::map) + .orEmpty() + + val groups = cursorToContactGroup.getContactGroupsCursor() + ?.map(cursorToContactGroup::map) + .orEmpty() + + groups.forEach { group -> + group.contacts.addAll(groupMembers + .filter { member -> member.groupId == group.id } + .mapNotNull { member -> contacts.find { contact -> contact.lookupKey == member.lookupKey } }) + } + + return groups + } + +} diff --git a/data/src/main/java/com/moez/QKSMS/util/ContactImageLoader.kt b/data/src/main/java/com/moez/QKSMS/util/ContactImageLoader.kt deleted file mode 100644 index c50ef35a56fc517dbd80eaed086ecd6a73398062..0000000000000000000000000000000000000000 --- a/data/src/main/java/com/moez/QKSMS/util/ContactImageLoader.kt +++ /dev/null @@ -1,103 +0,0 @@ -/* - * 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 com.moez.QKSMS.util - -import android.content.Context -import android.provider.ContactsContract -import com.bumptech.glide.Priority -import com.bumptech.glide.load.DataSource -import com.bumptech.glide.load.Key -import com.bumptech.glide.load.Options -import com.bumptech.glide.load.data.DataFetcher -import com.bumptech.glide.load.model.ModelLoader -import com.bumptech.glide.load.model.ModelLoaderFactory -import com.bumptech.glide.load.model.MultiModelLoaderFactory -import com.moez.QKSMS.repository.ContactRepository -import com.moez.QKSMS.repository.ContactRepositoryImpl -import io.reactivex.disposables.Disposable -import io.reactivex.schedulers.Schedulers -import java.io.InputStream -import java.security.MessageDigest - -class ContactImageLoader( - private val context: Context, - private val contactRepo: ContactRepository, - private val phoneNumberUtils: PhoneNumberUtils -) : ModelLoader { - - override fun handles(model: String): Boolean { - return model.startsWith("tel:") - } - - override fun buildLoadData( - model: String, - width: Int, - height: Int, - options: Options - ): ModelLoader.LoadData? { - return ModelLoader.LoadData( - ContactImageKey(phoneNumberUtils.normalizeNumber(model)), - ContactImageFetcher(context, contactRepo, model)) - } - - class Factory( - private val context: Context, - private val prefs: Preferences - ) : ModelLoaderFactory { - - override fun build(multiFactory: MultiModelLoaderFactory): ContactImageLoader { - return ContactImageLoader(context, ContactRepositoryImpl(context, prefs), PhoneNumberUtils(context)) - } - - override fun teardown() {} // nothing to do here - } - - class ContactImageKey(private val address: String) : Key { - override fun updateDiskCacheKey(digest: MessageDigest) = digest.update(address.toByteArray()) - } - - class ContactImageFetcher( - private val context: Context, - private val contactRepo: ContactRepository, - private val address: String - ) : DataFetcher { - - private var loadPhotoDisposable: Disposable? = null - - override fun cleanup() {} - override fun getDataClass() = InputStream::class.java - override fun getDataSource() = DataSource.LOCAL - - override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { - loadPhotoDisposable = contactRepo.findContactUri(address) - .map { uri -> - ContactsContract.Contacts.openContactPhotoInputStream(context.contentResolver, uri, true) - } - .subscribeOn(Schedulers.io()) - .subscribe( - { inputStream -> callback.onDataReady(inputStream) }, - { error -> callback.onLoadFailed(Exception(error)) }) - } - - override fun cancel() { - loadPhotoDisposable?.dispose() - } - } - -} \ No newline at end of file diff --git a/data/src/main/java/com/moez/QKSMS/util/GlideAppModule.kt b/data/src/main/java/com/moez/QKSMS/util/GlideAppModule.kt index f3b43507676395572afd21b260373fcf677b71f2..294a2c647f60f78296d4eb4fde3c9aaeffdd245d 100644 --- a/data/src/main/java/com/moez/QKSMS/util/GlideAppModule.kt +++ b/data/src/main/java/com/moez/QKSMS/util/GlideAppModule.kt @@ -19,15 +19,12 @@ package com.moez.QKSMS.util import android.content.Context -import android.preference.PreferenceManager import android.util.Log import com.bumptech.glide.Glide import com.bumptech.glide.GlideBuilder import com.bumptech.glide.Registry import com.bumptech.glide.annotation.GlideModule import com.bumptech.glide.module.AppGlideModule -import com.f2prateek.rx.preferences2.RxSharedPreferences -import java.io.InputStream @GlideModule class GlideAppModule : AppGlideModule() { @@ -37,10 +34,7 @@ class GlideAppModule : AppGlideModule() { } override fun registerComponents(context: Context, glide: Glide, registry: Registry) { - // TODO use DI to create the ContactImageLoader.Factory - val rxPrefs = RxSharedPreferences.create(PreferenceManager.getDefaultSharedPreferences(context)) - val prefs = Preferences(context, rxPrefs) - registry.append(String::class.java, InputStream::class.java, ContactImageLoader.Factory(context, prefs)) + // registry.prepend(GifDrawable::class.java, ReEncodingGifResourceEncoder(context, glide.bitmapPool)) } -} \ No newline at end of file +} diff --git a/data/src/main/java/com/moez/QKSMS/util/ImageUtils.kt b/data/src/main/java/com/moez/QKSMS/util/ImageUtils.kt index c9d613d833d0ce2c3697e725cdb7cb0b46b935d5..ff74b7fd56f7af0f0f6890ae9627266324431d4f 100644 --- a/data/src/main/java/com/moez/QKSMS/util/ImageUtils.kt +++ b/data/src/main/java/com/moez/QKSMS/util/ImageUtils.kt @@ -19,77 +19,35 @@ package com.moez.QKSMS.util import android.content.Context -import android.graphics.Bitmap import android.net.Uri -import com.bumptech.glide.load.resource.bitmap.CenterCrop import java.io.ByteArrayOutputStream object ImageUtils { - fun compressGif(context: Context, uri: Uri, maxBytes: Int): ByteArray { - val request = GlideApp + fun getScaledGif(context: Context, uri: Uri, maxWidth: Int, maxHeight: Int, quality: Int = 90): ByteArray { + val gif = GlideApp .with(context) .asGif() .load(uri) - .transform(CenterCrop()) - - val gif = request.submit().get() - - val width = gif.firstFrame.width - val height = gif.firstFrame.height - - val stream = ByteArrayOutputStream() - GifEncoder(context, GlideApp.get(context).bitmapPool) - .encodeTransformedToStream(gif, stream) - - val unscaledBytes = stream.size().toDouble() - - var attempts = 0 - var bytes = unscaledBytes - while (maxBytes > 0 && bytes > maxBytes) { - val scale = Math.sqrt(maxBytes / unscaledBytes) * (1 - attempts * 0.1) - - val scaledGif = request.submit((width * scale).toInt(), (height * scale).toInt()).get() - - stream.reset() - GifEncoder(context, GlideApp.get(context).bitmapPool) - .encodeTransformedToStream(scaledGif, stream) - - attempts++ - bytes = stream.size().toDouble() - } - - return stream.toByteArray() + .centerInside() + .encodeQuality(quality) + .submit(maxWidth, maxHeight) + .get() + + val outputStream = ByteArrayOutputStream() + GifEncoder(context, GlideApp.get(context).bitmapPool).encodeTransformedToStream(gif, outputStream) + return outputStream.toByteArray() } - fun compressBitmap(src: Bitmap, maxBytes: Int): ByteArray { - val quality = 90 - - val height = src.height - val width = src.width - - val stream = ByteArrayOutputStream() - src.compress(Bitmap.CompressFormat.JPEG, quality, stream) - - val unscaledBytes = stream.size().toDouble() - - // Based on the byte size of the bitmap, we'll try to reduce the image's dimensions such - // that it will fit within the max byte size set. If we don't get it right the first time, - // use a slightly heavier compression until we fit within the max size - var attempts = 0 - var bytes = unscaledBytes - while (maxBytes > 0 && bytes > maxBytes) { - val scale = Math.sqrt(maxBytes / unscaledBytes) * (1 - attempts * 0.1) - - stream.reset() - Bitmap.createScaledBitmap(src, (width * scale).toInt(), (height * scale).toInt(), true) - .compress(Bitmap.CompressFormat.JPEG, quality, stream) - - attempts++ - bytes = stream.size().toDouble() - } - - return stream.toByteArray().also { src.recycle() } + fun getScaledImage(context: Context, uri: Uri, maxWidth: Int, maxHeight: Int, quality: Int = 90): ByteArray { + return GlideApp + .with(context) + .`as`(ByteArray::class.java) + .load(uri) + .centerInside() + .encodeQuality(quality) + .submit(maxWidth, maxHeight) + .get() } } \ No newline at end of file diff --git a/domain/build.gradle b/domain/build.gradle index fe7c5c19e4b0d87b2a7d073e72ba0283b25ba4e8..5c7277b3850b7286fe9fe0f03a4dd8d5a6455cf1 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -19,6 +19,7 @@ apply plugin: 'com.android.library' apply plugin: 'realm-android' // Realm needs to be before Kotlin or the build will fail apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' android { @@ -31,7 +32,7 @@ android { defaultConfig { minSdkVersion 21 - targetSdkVersion 28 // Don't upgrade to 29 until we support Scoped storage + targetSdkVersion 29 } compileOptions { @@ -63,7 +64,6 @@ dependencies { // coroutines implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-reactive:$coroutines_version" diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/SetDefaultPhoneNumber.kt b/domain/src/main/java/com/moez/QKSMS/interactor/SetDefaultPhoneNumber.kt new file mode 100644 index 0000000000000000000000000000000000000000..639c154262f4dbb67f50ed841f334a1e6e7e4706 --- /dev/null +++ b/domain/src/main/java/com/moez/QKSMS/interactor/SetDefaultPhoneNumber.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.interactor + +import com.moez.QKSMS.repository.ContactRepository +import io.reactivex.Flowable +import javax.inject.Inject + +class SetDefaultPhoneNumber @Inject constructor( + private val contactRepo: ContactRepository +) : Interactor() { + + data class Params(val lookupKey: String, val phoneNumberId: Long) + + override fun buildObservable(params: Params): Flowable<*> { + return Flowable.just(params) + .doOnNext { (lookupKey, phoneNumberId) -> + contactRepo.setDefaultPhoneNumber(lookupKey, phoneNumberId) + } + } + +} diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/ContactSync.kt b/domain/src/main/java/com/moez/QKSMS/interactor/SyncContacts.kt similarity index 78% rename from domain/src/main/java/com/moez/QKSMS/interactor/ContactSync.kt rename to domain/src/main/java/com/moez/QKSMS/interactor/SyncContacts.kt index 7cd97fc73b9f50e92d97381ae616872dbd3b06e4..45fd8874e9abe487465612c75614894c35aedf82 100644 --- a/domain/src/main/java/com/moez/QKSMS/interactor/ContactSync.kt +++ b/domain/src/main/java/com/moez/QKSMS/interactor/SyncContacts.kt @@ -21,17 +21,15 @@ package com.moez.QKSMS.interactor import com.moez.QKSMS.repository.SyncRepository import io.reactivex.Flowable import timber.log.Timber -import java.util.concurrent.TimeUnit import javax.inject.Inject -open class ContactSync @Inject constructor(private val syncManager: SyncRepository) : Interactor() { +class SyncContacts @Inject constructor(private val syncManager: SyncRepository) : Interactor() { override fun buildObservable(params: Unit): Flowable { return Flowable.just(System.currentTimeMillis()) .doOnNext { syncManager.syncContacts() } .map { startTime -> System.currentTimeMillis() - startTime } - .map { elapsed -> TimeUnit.MILLISECONDS.toSeconds(elapsed) } - .doOnNext { seconds -> Timber.v("Completed sync in $seconds seconds") } + .doOnNext { duration -> Timber.v("Completed sync in ${duration}ms") } } } \ No newline at end of file diff --git a/domain/src/main/java/com/moez/QKSMS/listener/ContactAddedListener.kt b/domain/src/main/java/com/moez/QKSMS/listener/ContactAddedListener.kt index 1d2d1c5075524c764aa604b695d94367358d8055..800d834601f20de19a6989589f3f8cc0d5cba6da 100644 --- a/domain/src/main/java/com/moez/QKSMS/listener/ContactAddedListener.kt +++ b/domain/src/main/java/com/moez/QKSMS/listener/ContactAddedListener.kt @@ -22,6 +22,6 @@ import io.reactivex.Observable interface ContactAddedListener { - fun listen(address: String): Observable<*> + fun listen(): Observable<*> -} \ No newline at end of file +} diff --git a/domain/src/main/java/com/moez/QKSMS/manager/ActiveConversationManager.kt b/domain/src/main/java/com/moez/QKSMS/manager/ActiveConversationManager.kt index 35d125346acef9ae4cd8b9ed4be659a5d3b8feb3..4ed432d4c151d051185279d06b0f6b3c01cd3eb1 100644 --- a/domain/src/main/java/com/moez/QKSMS/manager/ActiveConversationManager.kt +++ b/domain/src/main/java/com/moez/QKSMS/manager/ActiveConversationManager.kt @@ -29,4 +29,4 @@ interface ActiveConversationManager { fun getActiveConversation(): Long? -} \ No newline at end of file +} diff --git a/domain/src/main/java/com/moez/QKSMS/manager/NotificationManager.kt b/domain/src/main/java/com/moez/QKSMS/manager/NotificationManager.kt index 0a31695a7c0d6e5bb1c99571e04e6c7fa185e98f..2af74aa71441a9bbce18101c9bbe0297fb0b3d37 100644 --- a/domain/src/main/java/com/moez/QKSMS/manager/NotificationManager.kt +++ b/domain/src/main/java/com/moez/QKSMS/manager/NotificationManager.kt @@ -26,10 +26,10 @@ interface NotificationManager { fun notifyFailed(threadId: Long) - fun createNotificationChannel(threadId: Long) + fun createNotificationChannel(threadId: Long = 0L) fun buildNotificationChannelId(threadId: Long): String fun getNotificationForBackup(): NotificationCompat.Builder -} \ No newline at end of file +} diff --git a/domain/src/main/java/com/moez/QKSMS/manager/ReferralManager.kt b/domain/src/main/java/com/moez/QKSMS/manager/ReferralManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..30bcfff4044863fb55ae46d996ddfc56d15ec530 --- /dev/null +++ b/domain/src/main/java/com/moez/QKSMS/manager/ReferralManager.kt @@ -0,0 +1,7 @@ +package com.moez.QKSMS.manager + +interface ReferralManager { + + suspend fun trackReferrer() + +} diff --git a/domain/src/main/java/com/moez/QKSMS/mapper/CursorToContactGroup.kt b/domain/src/main/java/com/moez/QKSMS/mapper/CursorToContactGroup.kt new file mode 100644 index 0000000000000000000000000000000000000000..2de28c6f4cb5f953b67f0949b372f26faaa651d2 --- /dev/null +++ b/domain/src/main/java/com/moez/QKSMS/mapper/CursorToContactGroup.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.mapper + +import android.database.Cursor +import com.moez.QKSMS.model.ContactGroup + +interface CursorToContactGroup : Mapper { + + fun getContactGroupsCursor(): Cursor? + +} diff --git a/domain/src/main/java/com/moez/QKSMS/mapper/CursorToContactGroupMember.kt b/domain/src/main/java/com/moez/QKSMS/mapper/CursorToContactGroupMember.kt new file mode 100644 index 0000000000000000000000000000000000000000..22861fdd4f074842eeb1df12c4759a1f246f0300 --- /dev/null +++ b/domain/src/main/java/com/moez/QKSMS/mapper/CursorToContactGroupMember.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.mapper + +import android.database.Cursor + +interface CursorToContactGroupMember : Mapper { + + data class GroupMember(val lookupKey: String, val groupId: Long) + + fun getGroupMembersCursor(): Cursor? + +} diff --git a/domain/src/main/java/com/moez/QKSMS/model/Contact.kt b/domain/src/main/java/com/moez/QKSMS/model/Contact.kt index e7e256379a0b86980479c07165e48635dbf65781..8c08e42afc6483368381b3bfbadc883ac7fda22d 100644 --- a/domain/src/main/java/com/moez/QKSMS/model/Contact.kt +++ b/domain/src/main/java/com/moez/QKSMS/model/Contact.kt @@ -26,5 +26,11 @@ open class Contact( @PrimaryKey var lookupKey: String = "", var numbers: RealmList = RealmList(), var name: String = "", + var photoUri: String? = null, + var starred: Boolean = false, var lastUpdate: Long = 0 -) : RealmObject() \ No newline at end of file +) : RealmObject() { + + fun getDefaultNumber(): PhoneNumber? = numbers.find { number -> number.isDefault } + +} diff --git a/domain/src/main/java/com/moez/QKSMS/model/ContactGroup.kt b/domain/src/main/java/com/moez/QKSMS/model/ContactGroup.kt new file mode 100644 index 0000000000000000000000000000000000000000..fd0e4363f90392ae3a7f18b15b702fd940c6b781 --- /dev/null +++ b/domain/src/main/java/com/moez/QKSMS/model/ContactGroup.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.model + +import io.realm.RealmList +import io.realm.RealmObject +import io.realm.annotations.PrimaryKey + +open class ContactGroup( + @PrimaryKey var id: Long = 0, + var title: String = "", + var contacts: RealmList = RealmList() +) : RealmObject() diff --git a/domain/src/main/java/com/moez/QKSMS/model/PhoneNumber.kt b/domain/src/main/java/com/moez/QKSMS/model/PhoneNumber.kt index 070fb130609ad74c41aee5f25f8227b2145c9965..4c4063546c55015445e18d65025bd482fbeec543 100644 --- a/domain/src/main/java/com/moez/QKSMS/model/PhoneNumber.kt +++ b/domain/src/main/java/com/moez/QKSMS/model/PhoneNumber.kt @@ -19,8 +19,12 @@ package com.moez.QKSMS.model import io.realm.RealmObject +import io.realm.annotations.PrimaryKey open class PhoneNumber( + @PrimaryKey var id: Long = 0, + var accountType: String? = "", var address: String = "", - var type: String = "" -) : RealmObject() \ No newline at end of file + var type: String = "", + var isDefault: Boolean = false +) : RealmObject() diff --git a/domain/src/main/java/com/moez/QKSMS/repository/ContactRepository.kt b/domain/src/main/java/com/moez/QKSMS/repository/ContactRepository.kt index 0373362610d413cc5b7792e61a62ec238c9d7513..b6f8011b15c75d7b911275eedef1bb71720c24cb 100644 --- a/domain/src/main/java/com/moez/QKSMS/repository/ContactRepository.kt +++ b/domain/src/main/java/com/moez/QKSMS/repository/ContactRepository.kt @@ -20,7 +20,8 @@ package com.moez.QKSMS.repository import android.net.Uri import com.moez.QKSMS.model.Contact -import io.reactivex.Flowable +import com.moez.QKSMS.model.ContactGroup +import io.reactivex.Observable import io.reactivex.Single import io.realm.RealmResults @@ -30,6 +31,12 @@ interface ContactRepository { fun getContacts(): RealmResults - fun getUnmanagedContacts(): Flowable> + fun getUnmanagedContact(lookupKey: String): Contact? -} \ No newline at end of file + fun getUnmanagedContacts(starred: Boolean = false): Observable> + + fun getUnmanagedContactGroups(): Observable> + + fun setDefaultPhoneNumber(lookupKey: String, phoneNumberId: Long) + +} diff --git a/domain/src/main/java/com/moez/QKSMS/repository/ConversationRepository.kt b/domain/src/main/java/com/moez/QKSMS/repository/ConversationRepository.kt index 7c517c105aa04d2ace3c152e9a21bddb61f8411d..7ccf33ff56832366022d733effc0e9b15e556187 100644 --- a/domain/src/main/java/com/moez/QKSMS/repository/ConversationRepository.kt +++ b/domain/src/main/java/com/moez/QKSMS/repository/ConversationRepository.kt @@ -21,6 +21,7 @@ package com.moez.QKSMS.repository import com.moez.QKSMS.model.Conversation import com.moez.QKSMS.model.Recipient import com.moez.QKSMS.model.SearchResult +import io.reactivex.Observable import io.realm.RealmResults interface ConversationRepository { @@ -51,6 +52,12 @@ interface ConversationRepository { */ fun getConversations(vararg threadIds: Long): RealmResults + fun getUnmanagedConversations(): Observable> + + fun getRecipients(): RealmResults + + fun getUnmanagedRecipients(): Observable> + fun getRecipient(recipientId: Long): Recipient? fun getThreadId(recipient: String): Long? diff --git a/domain/src/main/java/com/moez/QKSMS/repository/MessageRepository.kt b/domain/src/main/java/com/moez/QKSMS/repository/MessageRepository.kt index afb1a339d054de513308e4fb36b6ef493109ad8c..217e312a0319a86cb5dc1b1c4c693cd340a22b03 100644 --- a/domain/src/main/java/com/moez/QKSMS/repository/MessageRepository.kt +++ b/domain/src/main/java/com/moez/QKSMS/repository/MessageRepository.kt @@ -32,6 +32,8 @@ interface MessageRepository { fun getMessageForPart(id: Long): Message? + fun getLastIncomingMessage(threadId: Long): RealmResults + fun getUnreadCount(): Long fun getPart(id: Long): MmsPart? diff --git a/domain/src/main/java/com/moez/QKSMS/repository/SyncRepository.kt b/domain/src/main/java/com/moez/QKSMS/repository/SyncRepository.kt index 0638557a867735290be9d740bcabdd1146459b0f..f8f8b7def1482c85d063f16446ad80649d8fba21 100644 --- a/domain/src/main/java/com/moez/QKSMS/repository/SyncRepository.kt +++ b/domain/src/main/java/com/moez/QKSMS/repository/SyncRepository.kt @@ -25,7 +25,7 @@ import io.reactivex.Observable interface SyncRepository { sealed class SyncProgress { - class Idle : SyncProgress() + object Idle : SyncProgress() data class Running(val max: Int, val progress: Int, val indeterminate: Boolean) : SyncProgress() } @@ -37,11 +37,4 @@ interface SyncRepository { fun syncContacts() - /** - * Syncs a single contact to the Realm - * - * Return false if the contact couldn't be found - */ - fun syncContact(address: String): Boolean - -} \ No newline at end of file +} diff --git a/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt b/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt index 5e73c28968f2fb989f5844511d5340a39fbb55d1..d783e0249f5b4d48fb23ee8613c520959a0f3a1f 100644 --- a/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt +++ b/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt @@ -19,16 +19,22 @@ package com.moez.QKSMS.util import android.content.Context +import android.content.SharedPreferences import android.os.Build import android.provider.Settings import com.f2prateek.rx.preferences2.Preference import com.f2prateek.rx.preferences2.RxSharedPreferences import com.moez.QKSMS.common.util.extensions.versionCode +import io.reactivex.Observable import javax.inject.Inject import javax.inject.Singleton @Singleton -class Preferences @Inject constructor(context: Context, private val rxPrefs: RxSharedPreferences) { +class Preferences @Inject constructor( + context: Context, + private val rxPrefs: RxSharedPreferences, + private val sharedPrefs: SharedPreferences +) { companion object { const val NIGHT_MODE_SYSTEM = 0 @@ -69,6 +75,7 @@ class Preferences @Inject constructor(context: Context, private val rxPrefs: RxS } // Internal + val didSetReferrer = rxPrefs.getBoolean("didSetReferrer", false) val night = rxPrefs.getBoolean("night", false) val canUseSubId = rxPrefs.getBoolean("canUseSubId", true) val version = rxPrefs.getInteger("version", context.versionCode) @@ -84,6 +91,7 @@ class Preferences @Inject constructor(context: Context, private val rxPrefs: RxS val nightStart = rxPrefs.getString("nightStart", "18:00") val nightEnd = rxPrefs.getString("nightEnd", "6:00") val black = rxPrefs.getBoolean("black", false) + val autoColor = rxPrefs.getBoolean("autoColor", true) val systemFont = rxPrefs.getBoolean("systemFont", false) val textSize = rxPrefs.getInteger("textSize", TEXT_SIZE_NORMAL) val blockingManager = rxPrefs.getInteger("blockingManager", BLOCKING_MANAGER_QKSMS) @@ -101,6 +109,7 @@ class Preferences @Inject constructor(context: Context, private val rxPrefs: RxS val signature = rxPrefs.getString("signature", "") val unicode = rxPrefs.getBoolean("unicode", false) val mobileOnly = rxPrefs.getBoolean("mobileOnly", false) + val longAsMms = rxPrefs.getBoolean("longAsMms", false) val mmsSize = rxPrefs.getInteger("mmsSize", 300) val logging = rxPrefs.getBoolean("logging", false) @@ -118,12 +127,29 @@ class Preferences @Inject constructor(context: Context, private val rxPrefs: RxS } } - fun theme(threadId: Long = 0): Preference { - val default = rxPrefs.getInteger("theme", 0xFF0097A7.toInt()) + /** + * Returns a stream of preference keys for changing preferences + */ + val keyChanges: Observable = Observable.create { emitter -> + // Making this a lambda would cause it to be GCd + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + emitter.onNext(key) + } - return when (threadId) { - 0L -> default - else -> rxPrefs.getInteger("theme_$threadId", default.get()) + emitter.setCancellable { + sharedPrefs.unregisterOnSharedPreferenceChangeListener(listener) + } + + sharedPrefs.registerOnSharedPreferenceChangeListener(listener) + }.share() + + fun theme( + recipientId: Long = 0, + default: Int = rxPrefs.getInteger("theme", 0xFF0097A7.toInt()).get() + ): Preference { + return when (recipientId) { + 0L -> rxPrefs.getInteger("theme", 0xFF0097A7.toInt()) + else -> rxPrefs.getInteger("theme_$recipientId", default) } } @@ -145,6 +171,15 @@ class Preferences @Inject constructor(context: Context, private val rxPrefs: RxS } } + fun wakeScreen(threadId: Long = 0): Preference { + val default = rxPrefs.getBoolean("wake", false) + + return when (threadId) { + 0L -> default + else -> rxPrefs.getBoolean("wake_$threadId", default.get()) + } + } + fun vibration(threadId: Long = 0): Preference { val default = rxPrefs.getBoolean("vibration", true) @@ -162,4 +197,4 @@ class Preferences @Inject constructor(context: Context, private val rxPrefs: RxS else -> rxPrefs.getString("ringtone_$threadId", default.get()) } } -} \ No newline at end of file +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 6914a81f75ef85693e46ba0e16291df0ad72d9ae..4b9cec46b7008e35d83a353459d76b5877fa575c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Thu Jun 06 19:27:58 EDT 2019 +#Tue Dec 03 23:30:52 EST 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip diff --git a/presentation/build.gradle b/presentation/build.gradle index ccd4f721c65dc94c6fbed3e6956e58a1160fcaee..271e21dce2e69576eb928a610f25eab7e0a2803f 100644 --- a/presentation/build.gradle +++ b/presentation/build.gradle @@ -30,10 +30,13 @@ android { defaultConfig { applicationId "foundation.e.message" minSdkVersion 21 - targetSdkVersion 28 // Don't upgrade to 29 until we support Scoped storage - versionCode 2209 - versionName "3.7.10" + targetSdkVersion 29 + versionCode 2213 + versionName "3.8.1" + setProperty("archivesBaseName", "QKSMS-v${versionName}") testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + setProperty("archivesBaseName", "QKSMS-v${versionName}") } /* signingConfigs { diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index 2faa07aae86ad90dfe19fb40f00dad835f863a62..1f79498e39b0efb734e7355c8a73323f95ac08cf 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -40,6 +40,7 @@ android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:requestLegacyExternalStorage="true" android:supportsRtl="true" android:theme="@style/AppLaunchTheme"> @@ -108,6 +109,7 @@ android:windowSoftInputMode="adjustResize" /> + diff --git a/presentation/src/main/java/com/moez/QKSMS/common/MenuItemAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/common/MenuItemAdapter.kt index bc506cd802c61744a6de2f3363ee4218b426c037..6563054828051a8e2c378b865bb74decbad1e355 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/MenuItemAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/MenuItemAdapter.kt @@ -33,6 +33,7 @@ import com.moez.QKSMS.common.util.extensions.setVisible import io.reactivex.disposables.CompositeDisposable import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject +import kotlinx.android.synthetic.main.menu_list_item.* import kotlinx.android.synthetic.main.menu_list_item.view.* import javax.inject.Inject @@ -83,11 +84,10 @@ class MenuItemAdapter @Inject constructor(private val context: Context, private override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val menuItem = getItem(position) - val view = holder.containerView - view.title.text = menuItem.title - view.check.isActivated = (menuItem.actionId == selectedItem) - view.check.setVisible(selectedItem != null) + holder.title.text = menuItem.title + holder.check.isActivated = (menuItem.actionId == selectedItem) + holder.check.setVisible(selectedItem != null) } override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { diff --git a/presentation/src/main/java/com/moez/QKSMS/common/Navigator.kt b/presentation/src/main/java/com/moez/QKSMS/common/Navigator.kt index 5fb500979cfe189ba9326d814000d8bcc33dd2f6..d548c92db599e6dc414582959c3023630e8da095 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/Navigator.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/Navigator.kt @@ -222,14 +222,9 @@ class Navigator @Inject constructor( } fun addContact(address: String) { - val uri = Uri.parse("tel: $address") - var intent = Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT, uri) - - if (intent.resolveActivity(context.packageManager) == null) { - intent = Intent(Intent.ACTION_INSERT) - .setType(ContactsContract.Contacts.CONTENT_TYPE) - .putExtra(ContactsContract.Intents.Insert.PHONE, address) - } + val intent = Intent(Intent.ACTION_INSERT) + .setType(ContactsContract.Contacts.CONTENT_TYPE) + .putExtra(ContactsContract.Intents.Insert.PHONE, address) startActivityExternal(intent) } @@ -251,6 +246,17 @@ class Navigator @Inject constructor( startActivityExternal(intent) } + fun shareFile(file: File) { + val data = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.name.split(".").last()) + val intent = Intent(Intent.ACTION_SEND) + .setType(type) + .putExtra(Intent.EXTRA_STREAM, data) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + startActivityExternal(intent) + } + fun showNotificationSettings(threadId: Long = 0) { val intent = Intent(context, NotificationPrefsActivity::class.java) intent.putExtra("threadId", threadId) @@ -264,8 +270,8 @@ class Navigator @Inject constructor( } val channelId = notificationManager.buildNotificationChannelId(threadId) val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) - intent.putExtra(Settings.EXTRA_CHANNEL_ID, channelId) - intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + .putExtra(Settings.EXTRA_CHANNEL_ID, channelId) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) startActivity(intent) } } diff --git a/presentation/src/main/java/com/moez/QKSMS/common/QKApplication.kt b/presentation/src/main/java/com/moez/QKSMS/common/QKApplication.kt index bd5eec858de77d3938b767ebf31c5e00a17224cb..51440e10a12e0fbdd88c59b3324ab43785c5cb01 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/QKApplication.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/QKApplication.kt @@ -31,6 +31,7 @@ import com.moez.QKSMS.common.util.FileLoggingTree import com.moez.QKSMS.injection.AppComponentManager import com.moez.QKSMS.injection.appComponent import com.moez.QKSMS.manager.AnalyticsManager +import com.moez.QKSMS.manager.ReferralManager import com.moez.QKSMS.migration.QkMigration import com.moez.QKSMS.migration.QkRealmMigration import com.moez.QKSMS.util.NightModeManager @@ -43,6 +44,9 @@ import dagger.android.HasBroadcastReceiverInjector import dagger.android.HasServiceInjector import io.realm.Realm import io.realm.RealmConfiguration +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -62,22 +66,26 @@ class QKApplication : Application(), HasActivityInjector, HasBroadcastReceiverIn @Inject lateinit var fileLoggingTree: FileLoggingTree @Inject lateinit var nightModeManager: NightModeManager + @Inject lateinit var realmMigration: QkRealmMigration + @Inject lateinit var referralManager: ReferralManager override fun onCreate() { super.onCreate() + AppComponentManager.init(this) + appComponent.inject(this) + Realm.init(this) Realm.setDefaultConfiguration(RealmConfiguration.Builder() .compactOnLaunch() - .migration(QkRealmMigration()) - .schemaVersion(QkRealmMigration.SCHEMA_VERSION) + .migration(realmMigration) + .schemaVersion(QkRealmMigration.SchemaVersion) .build()) - AppComponentManager.init(this) - appComponent.inject(this) + qkMigration.performMigration() - packageManager.getInstallerPackageName(packageName)?.let { installer -> - analyticsManager.setUserProperty("Installer", installer) + GlobalScope.launch(Dispatchers.IO) { + referralManager.trackReferrer() } nightModeManager.updateCurrentTheme() diff --git a/presentation/src/main/java/com/moez/QKSMS/common/base/QkThemedActivity.kt b/presentation/src/main/java/com/moez/QKSMS/common/base/QkThemedActivity.kt index 660881e2a2ba903e967856662fc6f5f173d11fec..14b858b2c3e63bd499750b8b899b99bcad3bac45 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/base/QkThemedActivity.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/base/QkThemedActivity.kt @@ -30,6 +30,12 @@ import com.moez.QKSMS.R import com.moez.QKSMS.common.util.Colors import com.moez.QKSMS.common.util.extensions.resolveThemeBoolean import com.moez.QKSMS.common.util.extensions.resolveThemeColor +import com.moez.QKSMS.extensions.Optional +import com.moez.QKSMS.extensions.asObservable +import com.moez.QKSMS.extensions.mapNotNull +import com.moez.QKSMS.repository.ConversationRepository +import com.moez.QKSMS.repository.MessageRepository +import com.moez.QKSMS.util.PhoneNumberUtils import com.moez.QKSMS.util.Preferences import com.uber.autodispose.android.lifecycle.scope import com.uber.autodispose.autoDisposable @@ -51,6 +57,9 @@ import javax.inject.Inject abstract class QkThemedActivity : QkActivity() { @Inject lateinit var colors: Colors + @Inject lateinit var conversationRepo: ConversationRepository + @Inject lateinit var messageRepo: MessageRepository + @Inject lateinit var phoneNumberUtils: PhoneNumberUtils @Inject lateinit var prefs: Preferences /** @@ -61,10 +70,32 @@ abstract class QkThemedActivity : QkActivity() { /** * Switch the theme if the threadId changes + * Set it based on the latest message in the conversation */ - val theme = threadId + val theme: Observable = threadId .distinctUntilChanged() - .switchMap { threadId -> colors.themeObservable(threadId) } + .switchMap { threadId -> + val conversation = conversationRepo.getConversation(threadId) + when { + conversation == null -> Observable.just(Optional(null)) + + conversation.recipients.size == 1 -> Observable.just(Optional(conversation.recipients.first())) + + else -> messageRepo.getLastIncomingMessage(conversation.id) + .asObservable() + .mapNotNull { messages -> messages.firstOrNull() } + .distinctUntilChanged { message -> message.address } + .mapNotNull { message -> + conversation.recipients.find { recipient -> + phoneNumberUtils.compare(recipient.address, message.address) + } + } + .map { recipient -> Optional(recipient) } + .startWith(Optional(conversation.recipients.firstOrNull())) + .distinctUntilChanged() + } + } + .switchMap { colors.themeObservable(it.value) } @SuppressLint("InlinedApi") override fun onCreate(savedInstanceState: Bundle?) { diff --git a/presentation/src/main/java/com/moez/QKSMS/common/util/Colors.kt b/presentation/src/main/java/com/moez/QKSMS/common/util/Colors.kt index fd0d9f1b4818db78072a8e17bb5f0d38ee9a9db7..35dfe09c02d7c871d25476c3027f5f598d95fe9b 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/util/Colors.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/util/Colors.kt @@ -20,15 +20,23 @@ package com.moez.QKSMS.common.util import android.content.Context import android.graphics.Color +import androidx.core.content.res.getColorOrThrow import com.moez.QKSMS.R import com.moez.QKSMS.common.util.extensions.getColorCompat +import com.moez.QKSMS.model.Recipient +import com.moez.QKSMS.util.PhoneNumberUtils import com.moez.QKSMS.util.Preferences import io.reactivex.Observable import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.absoluteValue @Singleton -class Colors @Inject constructor(private val context: Context, private val prefs: Preferences) { +class Colors @Inject constructor( + private val context: Context, + private val phoneNumberUtils: PhoneNumberUtils, + private val prefs: Preferences +) { data class Theme(val theme: Int, private val colors: Colors) { val highlight by lazy { colors.highlightColorForTheme(theme) } @@ -37,27 +45,31 @@ class Colors @Inject constructor(private val context: Context, private val prefs val textTertiary by lazy { colors.textTertiaryOnThemeForColor(theme) } } - val materialColors = listOf( - listOf(0xffffebee, 0xffffcdd2, 0xffef9a9a, 0xffe57373, 0xffef5350, 0xfff44336, 0xffe53935, 0xffd32f2f, 0xffc62828, 0xffb71c1c), - listOf(0xffFCE4EC, 0xffF8BBD0, 0xffF48FB1, 0xffF06292, 0xffEC407A, 0xffE91E63, 0xffD81B60, 0xffC2185B, 0xffAD1457, 0xff880E4F), - listOf(0xffF3E5F5, 0xffE1BEE7, 0xffCE93D8, 0xffBA68C8, 0xffAB47BC, 0xff9C27B0, 0xff8E24AA, 0xff7B1FA2, 0xff6A1B9A, 0xff4A148C), - listOf(0xffEDE7F6, 0xffD1C4E9, 0xffB39DDB, 0xff9575CD, 0xff7E57C2, 0xff673AB7, 0xff5E35B1, 0xff512DA8, 0xff4527A0, 0xff311B92), - listOf(0xffE8EAF6, 0xffC5CAE9, 0xff9FA8DA, 0xff7986CB, 0xff5C6BC0, 0xff3F51B5, 0xff3949AB, 0xff303F9F, 0xff283593, 0xff1A237E), - listOf(0xffE3F2FD, 0xffBBDEFB, 0xff90CAF9, 0xff64B5F6, 0xff42A5F5, 0xff2196F3, 0xff1E88E5, 0xff1976D2, 0xff1565C0, 0xff0D47A1), - listOf(0xffE1F5FE, 0xffB3E5FC, 0xff81D4FA, 0xff4FC3F7, 0xff29B6F6, 0xff03A9F4, 0xff039BE5, 0xff0288D1, 0xff0277BD, 0xff01579B), - listOf(0xffE0F7FA, 0xffB2EBF2, 0xff80DEEA, 0xff4DD0E1, 0xff26C6DA, 0xff00BCD4, 0xff00ACC1, 0xff0097A7, 0xff00838F, 0xff006064), - listOf(0xffE0F2F1, 0xffB2DFDB, 0xff80CBC4, 0xff4DB6AC, 0xff26A69A, 0xff009688, 0xff00897B, 0xff00796B, 0xff00695C, 0xff004D40), - listOf(0xffE8F5E9, 0xffC8E6C9, 0xffA5D6A7, 0xff81C784, 0xff66BB6A, 0xff4CAF50, 0xff43A047, 0xff388E3C, 0xff2E7D32, 0xff1B5E20), - listOf(0xffF1F8E9, 0xffDCEDC8, 0xffC5E1A5, 0xffAED581, 0xff9CCC65, 0xff8BC34A, 0xff7CB342, 0xff689F38, 0xff558B2F, 0xff33691E), - listOf(0xffF9FBE7, 0xffF0F4C3, 0xffE6EE9C, 0xffDCE775, 0xffD4E157, 0xffCDDC39, 0xffC0CA33, 0xffAFB42B, 0xff9E9D24, 0xff827717), - listOf(0xffFFFDE7, 0xffFFF9C4, 0xffFFF59D, 0xffFFF176, 0xffFFEE58, 0xffFFEB3B, 0xffFDD835, 0xffFBC02D, 0xffF9A825, 0xffF57F17), - listOf(0xffFFF8E1, 0xffFFECB3, 0xffFFE082, 0xffFFD54F, 0xffFFCA28, 0xffFFC107, 0xffFFB300, 0xffFFA000, 0xffFF8F00, 0xffFF6F00), - listOf(0xffFFF3E0, 0xffFFE0B2, 0xffFFCC80, 0xffFFB74D, 0xffFFA726, 0xffFF9800, 0xffFB8C00, 0xffF57C00, 0xffEF6C00, 0xffE65100), - listOf(0xffFBE9E7, 0xffFFCCBC, 0xffFFAB91, 0xffFF8A65, 0xffFF7043, 0xffFF5722, 0xffF4511E, 0xffE64A19, 0xffD84315, 0xffBF360C), - listOf(0xffEFEBE9, 0xffD7CCC8, 0xffBCAAA4, 0xffA1887F, 0xff8D6E63, 0xff795548, 0xff6D4C41, 0xff5D4037, 0xff4E342E, 0xff3E2723), - listOf(0xffFAFAFA, 0xffF5F5F5, 0xffEEEEEE, 0xffE0E0E0, 0xffBDBDBD, 0xff9E9E9E, 0xff757575, 0xff616161, 0xff424242, 0xff212121), - listOf(0xffECEFF1, 0xffCFD8DC, 0xffB0BEC5, 0xff90A4AE, 0xff78909C, 0xff607D8B, 0xff546E7A, 0xff455A64, 0xff37474F, 0xff263238)) - .map { it.map { it.toInt() } } + val materialColors: List> = listOf( + R.array.material_red, + R.array.material_pink, + R.array.material_purple, + R.array.material_deep_purple, + R.array.material_indigo, + R.array.material_blue, + R.array.material_light_blue, + R.array.material_cyan, + R.array.material_teal, + R.array.material_green, + R.array.material_light_green, + R.array.material_lime, + R.array.material_yellow, + R.array.material_amber, + R.array.material_orange, + R.array.material_deep_orange, + R.array.material_brown, + R.array.material_gray, + R.array.material_blue_gray) + .map { res -> context.resources.obtainTypedArray(res) } + .map { typedArray -> (0 until typedArray.length()).map(typedArray::getColorOrThrow) } + + private val randomColors: List = context.resources.obtainTypedArray(R.array.random_colors) + .let { typedArray -> (0 until typedArray.length()).map(typedArray::getColorOrThrow) } private val minimumContrastRatio = 2 @@ -66,10 +78,22 @@ class Colors @Inject constructor(private val context: Context, private val prefs private val secondaryTextLuminance = measureLuminance(context.getColorCompat(R.color.textSecondaryDark)) private val tertiaryTextLuminance = measureLuminance(context.getColorCompat(R.color.textTertiaryDark)) - fun theme(threadId: Long = 0): Theme = Theme(prefs.theme(threadId).get(), this) + fun theme(recipient: Recipient? = null): Theme { + val pref = prefs.theme(recipient?.id ?: 0) + val color = when { + recipient == null || !prefs.autoColor.get() || pref.isSet -> pref.get() + else -> generateColor(recipient) + } + return Theme(color, this) + } - fun themeObservable(threadId: Long = 0): Observable { - return prefs.theme(threadId).asObservable() + fun themeObservable(recipient: Recipient? = null): Observable { + val pref = when { + recipient == null -> prefs.theme() + prefs.autoColor.get() -> prefs.theme(recipient.id, generateColor(recipient)) + else -> prefs.theme(recipient.id, prefs.theme().get()) + } + return pref.asObservable() .map { color -> Theme(color, this) } } @@ -110,4 +134,8 @@ class Colors @Inject constructor(private val context: Context, private val prefs return 0.2126 * array[0] + 0.7152 * array[1] + 0.0722 * array[2] + 0.05 } -} \ No newline at end of file + private fun generateColor(recipient: Recipient): Int { + val index = recipient.address.hashCode().absoluteValue % randomColors.size + return randomColors[index] + } +} diff --git a/presentation/src/main/java/com/moez/QKSMS/common/util/DateFormatter.kt b/presentation/src/main/java/com/moez/QKSMS/common/util/DateFormatter.kt index 00974bbc34c48809a2cc87d49a1d2deb4dd82e13..d4628c58f8fb94e599b201d7af540c11f5418442 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/util/DateFormatter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/util/DateFormatter.kt @@ -39,7 +39,10 @@ class DateFormatter @Inject constructor(val context: Context) { var formattedPattern = DateFormat.getBestDateTimePattern(Locale.getDefault(), pattern) if (DateFormat.is24HourFormat(context)) { - formattedPattern = formattedPattern.replace("h", "HH").replace(" a".toRegex(), "") + formattedPattern = formattedPattern + .replace("h", "HH") + .replace("K", "HH") + .replace(" a".toRegex(), "") } return SimpleDateFormat(formattedPattern, Locale.getDefault()) diff --git a/presentation/src/main/java/com/moez/QKSMS/common/util/NotificationManagerImpl.kt b/presentation/src/main/java/com/moez/QKSMS/common/util/NotificationManagerImpl.kt index 487b358afdcb6d7a92311c1011ec5b2b1fe6a6a4..1ba84dac3e4cbc173ba576104a75fd4921529098 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/util/NotificationManagerImpl.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/util/NotificationManagerImpl.kt @@ -18,7 +18,7 @@ */ package com.moez.QKSMS.common.util -import android.annotation.SuppressLint +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -26,14 +26,17 @@ import android.content.ContentUris import android.content.Context import android.content.Intent import android.graphics.Color +import android.media.AudioAttributes import android.net.Uri import android.os.Build +import android.os.PowerManager import android.provider.ContactsContract import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person import androidx.core.app.RemoteInput import androidx.core.app.TaskStackBuilder +import androidx.core.content.getSystemService import androidx.core.graphics.drawable.IconCompat import com.moez.QKSMS.R import com.moez.QKSMS.common.util.extensions.dpToPx @@ -76,19 +79,8 @@ class NotificationManagerImpl @Inject constructor( private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager init { - @SuppressLint("NewApi") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val name = "Default" - val importance = NotificationManager.IMPORTANCE_HIGH - val channel = NotificationChannel(DEFAULT_CHANNEL_ID, name, importance).apply { - enableLights(true) - lightColor = Color.WHITE - enableVibration(true) - vibrationPattern = VIBRATE_PATTERN - } - - notificationManager.createNotificationChannel(channel) - } + // Make sure the default channel has been initialized + createNotificationChannel() } /** @@ -109,24 +101,34 @@ class NotificationManagerImpl @Inject constructor( } val conversation = conversationRepo.getConversation(threadId) ?: return + val lastRecipient = conversation.lastMessage?.let { lastMessage -> + conversation.recipients.find { recipient -> + phoneNumberUtils.compare(recipient.address, lastMessage.address) + } + } ?: conversation.recipients.firstOrNull() val contentIntent = Intent(context, ComposeActivity::class.java).putExtra("threadId", threadId) val taskStackBuilder = TaskStackBuilder.create(context) - taskStackBuilder.addParentStack(ComposeActivity::class.java) - taskStackBuilder.addNextIntent(contentIntent) + .addParentStack(ComposeActivity::class.java) + .addNextIntent(contentIntent) val contentPI = taskStackBuilder.getPendingIntent(threadId.toInt() + 10000, PendingIntent.FLAG_UPDATE_CURRENT) val seenIntent = Intent(context, MarkSeenReceiver::class.java).putExtra("threadId", threadId) - val seenPI = PendingIntent.getBroadcast(context, threadId.toInt() + 20000, seenIntent, PendingIntent.FLAG_UPDATE_CURRENT) + val seenPI = PendingIntent.getBroadcast(context, threadId.toInt() + 20000, seenIntent, + PendingIntent.FLAG_UPDATE_CURRENT) // We can't store a null preference, so map it to a null Uri if the pref string is empty val ringtone = prefs.ringtone(threadId).get() .takeIf { it.isNotEmpty() } ?.let(Uri::parse) + ?.also { uri -> + // https://commonsware.com/blog/2016/09/07/notifications-sounds-android-7p0-aggravation.html + context.grantUriPermission("com.android.systemui", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } val notification = NotificationCompat.Builder(context, getChannelIdForNotification(threadId)) .setCategory(NotificationCompat.CATEGORY_MESSAGE) - .setColor(colors.theme(threadId).theme) + .setColor(colors.theme(lastRecipient).theme) .setPriority(NotificationCompat.PRIORITY_MAX) .setSmallIcon(R.drawable.ic_notification) .setNumber(messages.size) @@ -150,15 +152,15 @@ class NotificationManagerImpl @Inject constructor( val person = Person.Builder() if (!message.isMe()) { - val recipient = conversation.recipients - .firstOrNull { phoneNumberUtils.compare(it.address, message.address) } + val recipient = conversation.recipients.find { recipient -> + phoneNumberUtils.compare(recipient.address, message.address) + } person.setName(recipient?.getDisplayName() ?: message.address) - person.setIcon(GlideApp.with(context) .asBitmap() .circleCrop() - .load("tel:${message.address}") + .load(recipient?.contact?.photoUri) .submit(64.dpToPx(context), 64.dpToPx(context)) .let { futureGet -> tryOrNull(false) { futureGet.get() } } ?.let(IconCompat::createWithBitmap)) @@ -178,12 +180,12 @@ class NotificationManagerImpl @Inject constructor( // Set the large icon val avatar = conversation.recipients.takeIf { it.size == 1 } - ?.first()?.address - ?.let { address -> + ?.first()?.contact?.photoUri + ?.let { photoUri -> GlideApp.with(context) .asBitmap() .circleCrop() - .load("tel:$address") + .load(photoUri) .submit(64.dpToPx(context), 64.dpToPx(context)) } ?.let { futureGet -> tryOrNull(false) { futureGet.get() } } @@ -200,13 +202,15 @@ class NotificationManagerImpl @Inject constructor( notification .setLargeIcon(avatar) .setContentTitle(conversation.getTitle()) - .setContentText(context.resources.getQuantityString(R.plurals.notification_new_messages, messages.size, messages.size)) + .setContentText(context.resources.getQuantityString( + R.plurals.notification_new_messages, messages.size, messages.size)) } Preferences.NOTIFICATION_PREVIEWS_NONE -> { notification .setContentTitle(context.getString(R.string.app_name)) - .setContentText(context.resources.getQuantityString(R.plurals.notification_new_messages, messages.size, messages.size)) + .setContentText(context.resources.getQuantityString( + R.plurals.notification_new_messages, messages.size, messages.size)) } } @@ -225,7 +229,8 @@ class NotificationManagerImpl @Inject constructor( when (action) { Preferences.NOTIFICATION_ACTION_READ -> { val intent = Intent(context, MarkReadReceiver::class.java).putExtra("threadId", threadId) - val pi = PendingIntent.getBroadcast(context, threadId.toInt() + 30000, intent, PendingIntent.FLAG_UPDATE_CURRENT) + val pi = PendingIntent.getBroadcast(context, threadId.toInt() + 30000, intent, + PendingIntent.FLAG_UPDATE_CURRENT) NotificationCompat.Action.Builder(R.drawable.ic_check_white_24dp, actionLabels[action], pi) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ).build() } @@ -235,8 +240,10 @@ class NotificationManagerImpl @Inject constructor( getReplyAction(threadId) } else { val intent = Intent(context, QkReplyActivity::class.java).putExtra("threadId", threadId) - val pi = PendingIntent.getActivity(context, threadId.toInt() + 40000, intent, PendingIntent.FLAG_UPDATE_CURRENT) - NotificationCompat.Action.Builder(R.drawable.ic_reply_white_24dp, actionLabels[action], pi) + val pi = PendingIntent.getActivity(context, threadId.toInt() + 40000, intent, + PendingIntent.FLAG_UPDATE_CURRENT) + NotificationCompat.Action + .Builder(R.drawable.ic_reply_white_24dp, actionLabels[action], pi) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY).build() } } @@ -245,15 +252,19 @@ class NotificationManagerImpl @Inject constructor( val address = conversation.recipients[0]?.address val intentAction = if (permissions.hasCalling()) Intent.ACTION_CALL else Intent.ACTION_DIAL val intent = Intent(intentAction, Uri.parse("tel:$address")) - val pi = PendingIntent.getActivity(context, threadId.toInt() + 50000, intent, PendingIntent.FLAG_UPDATE_CURRENT) + val pi = PendingIntent.getActivity(context, threadId.toInt() + 50000, intent, + PendingIntent.FLAG_UPDATE_CURRENT) NotificationCompat.Action.Builder(R.drawable.ic_call_white_24dp, actionLabels[action], pi) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_CALL).build() } Preferences.NOTIFICATION_ACTION_DELETE -> { val messageIds = messages.map { it.id }.toLongArray() - val intent = Intent(context, DeleteMessagesReceiver::class.java).putExtra("threadId", threadId).putExtra("messageIds", messageIds) - val pi = PendingIntent.getBroadcast(context, threadId.toInt() + 60000, intent, PendingIntent.FLAG_UPDATE_CURRENT) + val intent = Intent(context, DeleteMessagesReceiver::class.java) + .putExtra("threadId", threadId) + .putExtra("messageIds", messageIds) + val pi = PendingIntent.getBroadcast(context, threadId.toInt() + 60000, intent, + PendingIntent.FLAG_UPDATE_CURRENT) NotificationCompat.Action.Builder(R.drawable.ic_delete_white_24dp, actionLabels[action], pi) .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_DELETE).build() } @@ -274,6 +285,17 @@ class NotificationManagerImpl @Inject constructor( } notificationManager.notify(threadId.toInt(), notification.build()) + + // Wake screen + if (prefs.wakeScreen(threadId).get()) { + context.getSystemService()?.let { powerManager -> + if (!powerManager.isInteractive) { + val flags = PowerManager.SCREEN_DIM_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP + val wakeLock = powerManager.newWakeLock(flags, context.packageName) + wakeLock.acquire(5000) + } + } + } } override fun notifyFailed(msgId: Long) { @@ -284,6 +306,12 @@ class NotificationManagerImpl @Inject constructor( } val conversation = conversationRepo.getConversation(message.threadId) ?: return + val lastRecipient = conversation.lastMessage?.let { lastMessage -> + conversation.recipients.find { recipient -> + phoneNumberUtils.compare(recipient.address, lastMessage.address) + } + } ?: conversation.recipients.firstOrNull() + val threadId = conversation.id val contentIntent = Intent(context, ComposeActivity::class.java).putExtra("threadId", threadId) @@ -295,7 +323,7 @@ class NotificationManagerImpl @Inject constructor( val notification = NotificationCompat.Builder(context, getChannelIdForNotification(threadId)) .setContentTitle(context.getString(R.string.notification_message_failed_title)) .setContentText(context.getString(R.string.notification_message_failed_text, conversation.getTitle())) - .setColor(colors.theme(threadId).theme) + .setColor(colors.theme(lastRecipient).theme) .setPriority(NotificationManagerCompat.IMPORTANCE_MAX) .setSmallIcon(R.drawable.ic_notification_failed) .setAutoCancel(true) @@ -309,9 +337,11 @@ class NotificationManagerImpl @Inject constructor( private fun getReplyAction(threadId: Long): NotificationCompat.Action { val replyIntent = Intent(context, RemoteMessagingReceiver::class.java).putExtra("threadId", threadId) - val replyPI = PendingIntent.getBroadcast(context, threadId.toInt() + 40000, replyIntent, PendingIntent.FLAG_UPDATE_CURRENT) + val replyPI = PendingIntent.getBroadcast(context, threadId.toInt() + 40000, replyIntent, + PendingIntent.FLAG_UPDATE_CURRENT) - val title = context.resources.getStringArray(R.array.notification_actions)[Preferences.NOTIFICATION_ACTION_REPLY] + val title = context.resources.getStringArray(R.array.notification_actions)[ + Preferences.NOTIFICATION_ACTION_REPLY] val responseSet = context.resources.getStringArray(R.array.qk_responses) val remoteInput = RemoteInput.Builder("body") .setLabel(title) @@ -331,22 +361,39 @@ class NotificationManagerImpl @Inject constructor( */ override fun createNotificationChannel(threadId: Long) { - // Only proceed if the android version supports notification channels - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + // Only proceed if the android version supports notification channels, and the channel hasn't + // already been created + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || getNotificationChannel(threadId) != null) { + return + } - conversationRepo.getConversation(threadId)?.let { conversation -> - val channelId = buildNotificationChannelId(threadId) - val name = conversation.getTitle() - val importance = NotificationManager.IMPORTANCE_HIGH - val channel = NotificationChannel(channelId, name, importance).apply { + val channel = when (threadId) { + 0L -> NotificationChannel(DEFAULT_CHANNEL_ID, "Default", NotificationManager.IMPORTANCE_HIGH).apply { enableLights(true) lightColor = Color.WHITE enableVibration(true) vibrationPattern = VIBRATE_PATTERN } - notificationManager.createNotificationChannel(channel) + else -> { + val conversation = conversationRepo.getConversation(threadId) ?: return + val channelId = buildNotificationChannelId(threadId) + val title = conversation.getTitle() + NotificationChannel(channelId, title, NotificationManager.IMPORTANCE_HIGH).apply { + enableLights(true) + lightColor = Color.WHITE + enableVibration(true) + vibrationPattern = VIBRATE_PATTERN + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + setSound(prefs.ringtone().get().let(Uri::parse), AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build()) + } + } } + + notificationManager.createNotificationChannel(channel) } /** @@ -356,9 +403,8 @@ class NotificationManagerImpl @Inject constructor( val channelId = buildNotificationChannelId(threadId) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return notificationManager - .notificationChannels - .firstOrNull { channel -> channel.id == channelId } + return notificationManager.notificationChannels + .find { channel -> channel.id == channelId } } return null @@ -372,13 +418,7 @@ class NotificationManagerImpl @Inject constructor( */ private fun getChannelIdForNotification(threadId: Long): String { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channelId = buildNotificationChannelId(threadId) - - return notificationManager - .notificationChannels - .map { channel -> channel.id } - .firstOrNull { id -> id == channelId } - ?: DEFAULT_CHANNEL_ID + return getNotificationChannel(threadId)?.id ?: DEFAULT_CHANNEL_ID } return DEFAULT_CHANNEL_ID diff --git a/presentation/src/main/java/com/moez/QKSMS/common/util/QkChooserTargetService.kt b/presentation/src/main/java/com/moez/QKSMS/common/util/QkChooserTargetService.kt index 0d97a67541cda01e373b92338d350a29bbd38858..13cd86b0c1f3d535ce5ec22aa16de24fb88f6e56 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/util/QkChooserTargetService.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/util/QkChooserTargetService.kt @@ -52,13 +52,13 @@ class QkChooserTargetService : ChooserTargetService() { } private fun createShortcutForConversation(conversation: Conversation): ChooserTarget { - val icon = when { - conversation.recipients.size == 1 -> { - val address = conversation.recipients.first()!!.address + val icon = when (conversation.recipients.size) { + 1 -> { + val photoUri = conversation.recipients.first()?.contact?.photoUri val request = GlideApp.with(this) .asBitmap() .circleCrop() - .load("tel:$address") + .load(photoUri) .submit() val bitmap = tryOrNull(false) { request.get() } diff --git a/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/ActivityExtensions.kt b/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/ActivityExtensions.kt index 83c103adfabb521d65cfe126d710c5f43b7acae7..3d4f59f59811a524d0602b1ab0da459564812a85 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/ActivityExtensions.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/ActivityExtensions.kt @@ -24,8 +24,7 @@ import android.view.inputmethod.InputMethodManager fun Activity.dismissKeyboard() { window.currentFocus?.let { focus -> - val imm = getSystemService( - Context.INPUT_METHOD_SERVICE) as InputMethodManager + val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(focus.windowToken, 0) focus.clearFocus() diff --git a/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/ViewExtensions.kt b/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/ViewExtensions.kt index 3adb55dc8fd65e1e07b382bcf674fa196f61c12f..f2bf92d1797269741f666e9a1c1d25ebc6c92bb5 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/ViewExtensions.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/ViewExtensions.kt @@ -39,11 +39,16 @@ var ViewGroup.animateLayoutChanges: Boolean layoutTransition = if (value) LayoutTransition() else null } - fun EditText.showKeyboard() { requestFocus() val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(this, 0) + imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) +} + +fun EditText.hideKeyboard() { + requestFocus() + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(windowToken, 0) } fun ImageView.setTint(color: Int) { @@ -59,7 +64,7 @@ fun View.setBackgroundTint(color: Int) { // API 21 doesn't support this if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { - background?.setColorFilter(color, PorterDuff.Mode.SRC_ATOP) + background?.setColorFilter(color, PorterDuff.Mode.SRC_IN) } backgroundTintList = ColorStateList.valueOf(color) @@ -115,14 +120,6 @@ fun ViewPager.addOnPageChangeListener(listener: (Int) -> Unit) { } fun RecyclerView.scrapViews() { - val adapter = adapter - val layoutManager = layoutManager - - this.adapter = null - this.layoutManager = null - - this.adapter = adapter - this.layoutManager = layoutManager - + recycledViewPool.clear() adapter?.notifyDataSetChanged() } diff --git a/presentation/src/main/java/com/moez/QKSMS/common/widget/AvatarView.kt b/presentation/src/main/java/com/moez/QKSMS/common/widget/AvatarView.kt index ba4e854a36915cc1077a476de6fd83d7fc0bda6e..054f20958abf90d2b37180684ba358b8086b14e4 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/widget/AvatarView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/widget/AvatarView.kt @@ -22,78 +22,51 @@ import android.content.Context import android.util.AttributeSet import android.view.View import android.widget.FrameLayout -import com.bumptech.glide.signature.ObjectKey import com.moez.QKSMS.R import com.moez.QKSMS.common.Navigator import com.moez.QKSMS.common.util.Colors import com.moez.QKSMS.common.util.extensions.setBackgroundTint import com.moez.QKSMS.common.util.extensions.setTint import com.moez.QKSMS.injection.appComponent -import com.moez.QKSMS.model.Contact import com.moez.QKSMS.model.Recipient import com.moez.QKSMS.util.GlideApp import kotlinx.android.synthetic.main.avatar_view.view.* import javax.inject.Inject -class AvatarView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : FrameLayout(context, attrs) { +class AvatarView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : FrameLayout(context, attrs) { @Inject lateinit var colors: Colors @Inject lateinit var navigator: Navigator - /** - * This value can be changes if we should use the theme from a particular conversation - */ - var threadId: Long = 0 - set(value) { - if (field == value) return - field = value - applyTheme(value) - } - private var lookupKey: String? = null private var name: String? = null - private var address: String? = null + private var photoUri: String? = null private var lastUpdated: Long? = null + private var theme: Colors.Theme init { if (!isInEditMode) { appComponent.inject(this) } - View.inflate(context, R.layout.avatar_view, this) + theme = colors.theme() + View.inflate(context, R.layout.avatar_view, this) setBackgroundResource(R.drawable.circle) clipToOutline = true } - /** - * If the [recipient] has a contact: use the contact's avatar, but keep the address. - * Use the recipient address otherwise. - */ - fun setContact(recipient: Recipient?) { - // If the recipient has a contact, just use that and return - recipient?.contact?.let { contact -> - setContact(contact, recipient.address) - return - } - - lookupKey = null - name = null - address = recipient?.address - lastUpdated = 0 - updateView() - } - /** * Use the [contact] information to display the avatar. - * A specific [contactAddress] can be specified (useful when the contact has several addresses). */ - fun setContact(contact: Contact?, contactAddress: String? = null) { - lookupKey = contact?.lookupKey - name = contact?.name - // If a contactAddress has been given, we use it. Use the contact address otherwise. - address = contactAddress ?: contact?.numbers?.firstOrNull()?.address - lastUpdated = contact?.lastUpdate + fun setRecipient(recipient: Recipient?) { + lookupKey = recipient?.contact?.lookupKey + name = recipient?.contact?.name + photoUri = recipient?.contact?.photoUri + lastUpdated = recipient?.contact?.lastUpdate + theme = colors.theme(recipient) updateView() } @@ -101,22 +74,22 @@ class AvatarView @JvmOverloads constructor(context: Context, attrs: AttributeSet super.onFinishInflate() if (!isInEditMode) { - applyTheme(threadId) updateView() } } - fun applyTheme(threadId: Long) { - colors.theme(threadId).run { - setBackgroundTint(theme) - initial.setTextColor(textPrimary) - icon.setTint(textPrimary) - } - } - private fun updateView() { + // Apply theme + setBackgroundTint(theme.theme) + initial.setTextColor(theme.textPrimary) + icon.setTint(theme.textPrimary) + if (name?.isNotEmpty() == true) { - initial.text = name?.substring(0, 1) + val initials = name?.split(" ").orEmpty() + .filter { name -> name.isNotEmpty() } + .map { name -> name[0].toString() } + + initial.text = if (initials.size > 1) initials.first() + initials.last() else initials.first() icon.visibility = GONE } else { initial.text = null @@ -124,11 +97,10 @@ class AvatarView @JvmOverloads constructor(context: Context, attrs: AttributeSet } photo.setImageDrawable(null) - address?.let { address -> + photoUri?.let { photoUri -> GlideApp.with(photo) - .load("tel:$address") - .signature(ObjectKey(lastUpdated ?: 0L)) + .load(photoUri) .into(photo) } } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/moez/QKSMS/common/widget/GroupAvatarView.kt b/presentation/src/main/java/com/moez/QKSMS/common/widget/GroupAvatarView.kt index 0021a7e42a96c6ac01ddcf13e4dcaf3d1ac55afc..85b240a1570d775b27104bc31571aba228ac27cb 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/widget/GroupAvatarView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/widget/GroupAvatarView.kt @@ -19,52 +19,53 @@ package com.moez.QKSMS.common.widget import android.content.Context -import android.os.Build import android.util.AttributeSet import android.view.View import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import com.moez.QKSMS.R +import com.moez.QKSMS.common.util.extensions.getColorCompat +import com.moez.QKSMS.common.util.extensions.resolveThemeColor +import com.moez.QKSMS.common.util.extensions.setBackgroundTint import com.moez.QKSMS.model.Recipient import kotlinx.android.synthetic.main.group_avatar_view.view.* -class GroupAvatarView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : ConstraintLayout(context, attrs) { +class GroupAvatarView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : ConstraintLayout(context, attrs) { - var contacts: List = ArrayList() + var recipients: List = ArrayList() set(value) { - field = value + field = value.sortedWith(compareByDescending { contact -> contact.contact?.lookupKey }) updateView() } - private val avatars by lazy { listOf(avatar1, avatar2, avatar3) } - init { View.inflate(context, R.layout.group_avatar_view, this) - setBackgroundResource(R.drawable.circle) - clipToOutline = true } override fun onFinishInflate() { super.onFinishInflate() - avatars.forEach { avatar -> - avatar.setBackgroundResource(R.drawable.rectangle) - - // If we're on API 21 we need to reapply the tint after changing the background - if (Build.VERSION.SDK_INT < 22) { - avatar.applyTheme(0) - } - } - if (!isInEditMode) { updateView() } } private fun updateView() { - avatars.forEachIndexed { index, avatar -> - avatar.visibility = if (contacts.size > index) View.VISIBLE else View.GONE - avatar.setContact(contacts.getOrNull(index)) + avatar1Frame.setBackgroundTint(when (recipients.size > 1) { + true -> context.resolveThemeColor(android.R.attr.windowBackground) + false -> context.getColorCompat(android.R.color.transparent) + }) + avatar1Frame.updateLayoutParams { + matchConstraintPercentWidth = if (recipients.size > 1) 0.75f else 1.0f } + avatar2.isVisible = recipients.size > 1 + + + recipients.getOrNull(0).run(avatar1::setRecipient) + recipients.getOrNull(1).run(avatar2::setRecipient) } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/moez/QKSMS/common/widget/PagerTitleView.kt b/presentation/src/main/java/com/moez/QKSMS/common/widget/PagerTitleView.kt index 3f2ff65feb7e3b7860c5625a04832e49da410646..0d3d5db6bb9683044b86acffbf924a320e536df2 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/widget/PagerTitleView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/widget/PagerTitleView.kt @@ -29,7 +29,9 @@ import com.moez.QKSMS.R import com.moez.QKSMS.common.util.Colors import com.moez.QKSMS.common.util.extensions.forEach import com.moez.QKSMS.common.util.extensions.resolveThemeColor +import com.moez.QKSMS.extensions.Optional import com.moez.QKSMS.injection.appComponent +import com.moez.QKSMS.repository.ConversationRepository import com.uber.autodispose.android.ViewScopeProvider import com.uber.autodispose.autoDisposable import io.reactivex.subjects.BehaviorSubject @@ -40,8 +42,9 @@ import javax.inject.Inject class PagerTitleView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : LinearLayout(context, attrs) { @Inject lateinit var colors: Colors + @Inject lateinit var conversationRepo: ConversationRepository - private val threadId: Subject = BehaviorSubject.create() + private val recipientId: Subject = BehaviorSubject.create() var pager: ViewPager? = null set(value) { @@ -55,8 +58,8 @@ class PagerTitleView @JvmOverloads constructor(context: Context, attrs: Attribut if (!isInEditMode) appComponent.inject(this) } - fun setThreadId(id: Long) { - threadId.onNext(id) + fun setRecipientId(id: Long) { + recipientId.onNext(id) } private fun recreate() { @@ -90,9 +93,10 @@ class PagerTitleView @JvmOverloads constructor(context: Context, attrs: Attribut intArrayOf(android.R.attr.state_activated), intArrayOf(-android.R.attr.state_activated)) - threadId + recipientId .distinctUntilChanged() - .switchMap { threadId -> colors.themeObservable(threadId) } + .map { recipientId -> Optional(conversationRepo.getRecipient(recipientId)) } + .switchMap { recipient -> colors.themeObservable(recipient.value) } .map { theme -> val textSecondary = context.resolveThemeColor(android.R.attr.textColorSecondary) ColorStateList(states, intArrayOf(theme.theme, textSecondary)) diff --git a/presentation/src/main/java/com/moez/QKSMS/common/widget/QkDialog.kt b/presentation/src/main/java/com/moez/QKSMS/common/widget/QkDialog.kt new file mode 100644 index 0000000000000000000000000000000000000000..f275c8a0c22d74ece3e6919f9f406b72cced6d88 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/common/widget/QkDialog.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.common.widget + +import android.app.Activity +import android.view.LayoutInflater +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import com.moez.QKSMS.R +import com.moez.QKSMS.common.base.QkAdapter +import kotlinx.android.synthetic.main.qk_dialog.view.* + +class QkDialog(private val context: Activity) : AlertDialog(context) { + + private val view = LayoutInflater.from(context).inflate(R.layout.qk_dialog, null) + + @StringRes + var titleRes: Int? = null + set(value) { + field = value + title = value?.let(context::getString) + } + + var title: String? = null + set(value) { + field = value + view.title.text = value + view.title.isVisible = !value.isNullOrBlank() + } + + @StringRes + var subtitleRes: Int? = null + set(value) { + field = value + subtitle = value?.let(context::getString) + } + + var subtitle: String? = null + set(value) { + field = value + view.subtitle.text = value + view.subtitle.isVisible = !value.isNullOrBlank() + } + + var adapter: QkAdapter<*>? = null + set(value) { + field = value + view.list.isVisible = value != null + view.list.adapter = value + } + + var positiveButtonListener: (() -> Unit)? = null + + @StringRes + var positiveButton: Int? = null + set(value) { + field = value + value?.run(view.positiveButton::setText) + view.positiveButton.isVisible = value != null + view.positiveButton.setOnClickListener { + positiveButtonListener?.invoke() ?: dismiss() + } + } + + var negativeButtonListener: (() -> Unit)? = null + + @StringRes + var negativeButton: Int? = null + set(value) { + field = value + value?.run(view.negativeButton::setText) + view.negativeButton.isVisible = value != null + view.negativeButton.setOnClickListener { + negativeButtonListener?.invoke() ?: dismiss() + } + } + + var cancelListener: (() -> Unit)? = null + set(value) { + field = value + setOnCancelListener { value?.invoke() } + } + + init { + setView(view) + } + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/backup/BackupAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/backup/BackupAdapter.kt index ff9270e8f57bc3ff5d8df47231ec83c21a11810b..038295dc663993379c988dc5c002a420abcb8800 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/backup/BackupAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/backup/BackupAdapter.kt @@ -29,7 +29,7 @@ import com.moez.QKSMS.common.util.DateFormatter import com.moez.QKSMS.model.BackupFile import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject -import kotlinx.android.synthetic.main.backup_list_item.view.* +import kotlinx.android.synthetic.main.backup_list_item.* import javax.inject.Inject class BackupAdapter @Inject constructor( @@ -50,13 +50,12 @@ class BackupAdapter @Inject constructor( override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val backup = getItem(position) - val view = holder.containerView val count = backup.messages - view.title.text = dateFormatter.getDetailedTimestamp(backup.date) - view.messages.text = context.resources.getQuantityString(R.plurals.backup_message_count, count, count) - view.size.text = Formatter.formatFileSize(context, backup.size) + holder.title.text = dateFormatter.getDetailedTimestamp(backup.date) + holder.messages.text = context.resources.getQuantityString(R.plurals.backup_message_count, count, count) + holder.size.text = Formatter.formatFileSize(context, backup.size) } } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/messages/BlockedMessagesAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/messages/BlockedMessagesAdapter.kt index 222e6d36223314faed11bfb330e26ab5db699ab3..9a4c37e38ba2ff8bc683aa975003016ad5ba2cbe 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/messages/BlockedMessagesAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/messages/BlockedMessagesAdapter.kt @@ -31,6 +31,7 @@ import com.moez.QKSMS.common.util.extensions.resolveThemeColor import com.moez.QKSMS.model.Conversation import com.moez.QKSMS.util.Preferences import io.reactivex.subjects.PublishSubject +import kotlinx.android.synthetic.main.blocked_list_item.* import kotlinx.android.synthetic.main.blocked_list_item.view.* import javax.inject.Inject @@ -69,24 +70,23 @@ class BlockedMessagesAdapter @Inject constructor( override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val conversation = getItem(position) ?: return - val view = holder.containerView - view.isActivated = isSelected(conversation.id) + holder.containerView.isActivated = isSelected(conversation.id) - view.avatars.contacts = conversation.recipients - view.title.collapseEnabled = conversation.recipients.size > 1 - view.title.text = conversation.getTitle() - view.date.text = dateFormatter.getConversationTimestamp(conversation.date) + holder.avatars.recipients = conversation.recipients + holder.title.collapseEnabled = conversation.recipients.size > 1 + holder.title.text = conversation.getTitle() + holder.date.text = dateFormatter.getConversationTimestamp(conversation.date) - view.blocker.text = when (conversation.blockingClient) { + holder.blocker.text = when (conversation.blockingClient) { Preferences.BLOCKING_MANAGER_CC -> context.getString(R.string.blocking_manager_call_control_title) Preferences.BLOCKING_MANAGER_SIA -> context.getString(R.string.blocking_manager_sia_title) else -> null } - view.reason.text = conversation.blockReason - view.blocker.isVisible = view.blocker.text.isNotEmpty() - view.reason.isVisible = view.blocker.text.isNotEmpty() + holder.reason.text = conversation.blockReason + holder.blocker.isVisible = holder.blocker.text.isNotEmpty() + holder.reason.isVisible = holder.blocker.text.isNotEmpty() } override fun getItemViewType(position: Int): Int { diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/AttachmentAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/AttachmentAdapter.kt index 82b91040369f15ca7c24eadc9366f5dfc6da22e5..7c6ae2daeba0f5f1bd2f32b7af2e96f8caee41df 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/AttachmentAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/AttachmentAdapter.kt @@ -34,7 +34,8 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject -import kotlinx.android.synthetic.main.attachment_contact_list_item.view.* +import kotlinx.android.synthetic.main.attachment_contact_list_item.* +import kotlinx.android.synthetic.main.attachment_image_list_item.* import kotlinx.android.synthetic.main.attachment_image_list_item.view.* import javax.inject.Inject @@ -70,18 +71,17 @@ class AttachmentAdapter @Inject constructor( override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val attachment = getItem(position) - val view = holder.containerView when (attachment) { is Attachment.Image -> Glide.with(context) .load(attachment.getUri()) - .into(view.thumbnail) + .into(holder.thumbnail) is Attachment.Contact -> Observable.just(attachment.vCard) .mapNotNull { vCard -> Ezvcard.parse(vCard).first() } .subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe( { vcard -> view.name?.text = vcard.formattedName.value }, { throwable -> Log.i("AttachmentAdapter.kt", "Name field is null") } ) + .subscribe( { vcard -> holder.name?.text = vcard.formattedName.value }, { throwable -> Log.i("AttachmentAdapter.kt", "Name field is null") } ) } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ChipsAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ChipsAdapter.kt deleted file mode 100755 index 2d7d7d50a177d330e699689c710a9228ccbab1d3..0000000000000000000000000000000000000000 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ChipsAdapter.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * 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 com.moez.QKSMS.feature.compose - -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.RelativeLayout -import androidx.recyclerview.widget.RecyclerView -import com.google.android.flexbox.FlexboxLayoutManager -import com.jakewharton.rxbinding2.widget.editorActions -import com.jakewharton.rxbinding2.widget.textChanges -import com.moez.QKSMS.R -import com.moez.QKSMS.common.base.QkAdapter -import com.moez.QKSMS.common.base.QkViewHolder -import com.moez.QKSMS.common.util.extensions.dpToPx -import com.moez.QKSMS.common.util.extensions.resolveThemeColor -import com.moez.QKSMS.common.util.extensions.showKeyboard -import com.moez.QKSMS.common.widget.QkEditText -import com.moez.QKSMS.model.Contact -import io.reactivex.subjects.PublishSubject -import kotlinx.android.synthetic.main.contact_chip.view.* -import javax.inject.Inject - -class ChipsAdapter @Inject constructor(private val context: Context) : QkAdapter() { - - companion object { - private const val TYPE_EDIT_TEXT = 0 - private const val TYPE_ITEM = 1 - } - - private val hint: String = context.getString(R.string.title_compose) - private val editText = View.inflate(context, R.layout.chip_input_list_item, null) as QkEditText - - var view: RecyclerView? = null - val chipDeleted: PublishSubject = PublishSubject.create() - val textChanges = editText.textChanges() - val actions = editText.editorActions() - val backspaces = editText.backspaces - - init { - val wrap = ViewGroup.LayoutParams.WRAP_CONTENT - editText.layoutParams = FlexboxLayoutManager.LayoutParams(wrap, wrap).apply { - minHeight = 36.dpToPx(context) - minWidth = 56.dpToPx(context) - flexGrow = 8f - } - - editText.hint = hint - } - - override fun onDatasetChanged() { - editText.text = null - editText.hint = if (itemCount == 1) hint else null - - if (itemCount != 2) { - editText.showKeyboard() - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { - TYPE_EDIT_TEXT -> { - editText.setTextColor(parent.context.resolveThemeColor(android.R.attr.textColorPrimary)) - editText.setHintTextColor(parent.context.resolveThemeColor(android.R.attr.textColorTertiary)) - QkViewHolder(editText) - } - - else -> { - val inflater = LayoutInflater.from(parent.context) - val view = inflater.inflate(R.layout.contact_chip, parent, false) - QkViewHolder(view).apply { - view.setOnClickListener { - val contact = getItem(adapterPosition) - showDetailedChip(view.context, contact) - } - } - } - } - - override fun onBindViewHolder(holder: QkViewHolder, position: Int) { - when (getItemViewType(position)) { - TYPE_ITEM -> { - val contact = getItem(position) - val view = holder.containerView - - view.avatar.setContact(contact) - - // If the contact's name is empty, try to display a phone number instead - // The contacts provided here should only have one number - view.name.text = if (contact.name.isNotBlank()) { - contact.name - } else { - contact.numbers.firstOrNull { it.address.isNotBlank() }?.address ?: "" - } - } - } - } - - override fun getItemCount() = super.getItemCount() + 1 - - override fun getItemViewType(position: Int) = if (position == itemCount - 1) TYPE_EDIT_TEXT else TYPE_ITEM - - /** - * The [context] has to come from a view, because we're inflating a view that used themed attrs - */ - private fun showDetailedChip(context: Context, contact: Contact) { - val detailedChipView = DetailedChipView(context) - detailedChipView.setContact(contact) - - val rootView = view?.rootView as ViewGroup - - val layoutParams = RelativeLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT) - - layoutParams.topMargin = 24.dpToPx(context) - layoutParams.marginStart = 56.dpToPx(context) - - rootView.addView(detailedChipView, layoutParams) - detailedChipView.show() - - detailedChipView.setOnDeleteListener { - chipDeleted.onNext(contact) - detailedChipView.hide() - } - } - - override fun areItemsTheSame(old: Contact, new: Contact): Boolean { - return old.lookupKey == new.lookupKey - } -} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt index 6f894223a596bb6de44c692a23bcbff69cab6176..536e0e944a4b28da0f0e66b3ba2ee32b87805d20 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivity.kt @@ -49,14 +49,17 @@ import com.moez.QKSMS.common.Navigator import com.moez.QKSMS.common.base.QkThemedActivity import com.moez.QKSMS.common.util.DateFormatter import com.moez.QKSMS.common.util.extensions.autoScrollToStart +import com.moez.QKSMS.common.util.extensions.hideKeyboard import com.moez.QKSMS.common.util.extensions.resolveThemeColor import com.moez.QKSMS.common.util.extensions.scrapViews import com.moez.QKSMS.common.util.extensions.setBackgroundTint import com.moez.QKSMS.common.util.extensions.setTint import com.moez.QKSMS.common.util.extensions.setVisible import com.moez.QKSMS.common.util.extensions.showKeyboard +import com.moez.QKSMS.feature.compose.editing.ChipsAdapter +import com.moez.QKSMS.feature.contacts.ContactsActivity import com.moez.QKSMS.model.Attachment -import com.moez.QKSMS.model.Contact +import com.moez.QKSMS.model.Recipient import com.uber.autodispose.android.lifecycle.scope import com.uber.autodispose.autoDisposable import dagger.android.AndroidInjection @@ -67,29 +70,29 @@ import kotlinx.android.synthetic.main.compose_activity.* import java.text.SimpleDateFormat import java.util.* import javax.inject.Inject +import kotlin.collections.HashMap class ComposeActivity : QkThemedActivity(), ComposeView { companion object { - private const val CAMERA_REQUEST_CODE = 0 - private const val GALLERY_REQUEST_CODE = 1 - private const val CONTACT_REQUEST_CODE = 2 + private const val SelectContactRequestCode = 0 + private const val TakePhotoRequestCode = 1 + private const val AttachPhotoRequestCode = 2 + private const val AttachContactRequestCode = 3 + + private const val CameraDestinationKey = "camera_destination" } @Inject lateinit var attachmentAdapter: AttachmentAdapter @Inject lateinit var chipsAdapter: ChipsAdapter - @Inject lateinit var contactsAdapter: ContactAdapter @Inject lateinit var dateFormatter: DateFormatter @Inject lateinit var messageAdapter: MessagesAdapter @Inject lateinit var navigator: Navigator @Inject lateinit var viewModelFactory: ViewModelProvider.Factory override val activityVisibleIntent: Subject = PublishSubject.create() - override val queryChangedIntent: Observable by lazy { chipsAdapter.textChanges } - override val queryBackspaceIntent: Observable<*> by lazy { chipsAdapter.backspaces } - override val queryEditorActionIntent: Observable by lazy { chipsAdapter.actions } - override val chipSelectedIntent: Subject by lazy { contactsAdapter.contactSelected } - override val chipDeletedIntent: Subject by lazy { chipsAdapter.chipDeleted } + override val chipsSelectedIntent: Subject> = PublishSubject.create() + override val chipDeletedIntent: Subject by lazy { chipsAdapter.chipDeleted } override val menuReadyIntent: Observable = menu.map { Unit } override val optionsItemIntent: Subject = PublishSubject.create() override val sendAsGroupIntent by lazy { sendAsGroupBackground.clicks() } @@ -132,7 +135,6 @@ class ComposeActivity : QkThemedActivity(), ComposeView { chipsAdapter.view = chips - contacts.itemAnimator = null chips.itemAnimator = null chips.layoutManager = FlexboxLayoutManager(this) @@ -152,14 +154,13 @@ class ComposeActivity : QkThemedActivity(), ComposeView { .doOnNext { attach.setTint(it.textPrimary) } .doOnNext { messageAdapter.theme = it } .autoDisposable(scope()) - .subscribe { messageList.scrapViews() } + .subscribe() window.callback = ComposeWindowCallback(window.callback, this) // These theme attributes don't apply themselves on API 21 if (Build.VERSION.SDK_INT <= 22) { messageBackground.setBackgroundTint(resolveThemeColor(R.attr.bubbleColor)) - composeBackground.setBackgroundTint(resolveThemeColor(R.attr.composeBackground)) } } override fun onResume() { @@ -197,7 +198,7 @@ class ComposeActivity : QkThemedActivity(), ComposeView { return } - threadId.onNext(state.selectedConversation) + threadId.onNext(state.threadId) title = when { state.selectedMessages > 0 -> getString(R.string.compose_title_selected, state.selectedMessages) @@ -206,20 +207,22 @@ class ComposeActivity : QkThemedActivity(), ComposeView { } toolbarSubtitle.setVisible(state.query.isNotEmpty()) - toolbarSubtitle.text = getString(R.string.compose_subtitle_results, state.searchSelectionPosition, state.searchResults) + toolbarSubtitle.text = getString(R.string.compose_subtitle_results, state.searchSelectionPosition, + state.searchResults) toolbarTitle.setVisible(!state.editingMode) chips.setVisible(state.editingMode) - contacts.setVisible(state.contactsVisible) - composeBar.setVisible(!state.contactsVisible && !state.loading) + composeBar.setVisible(!state.loading) // Don't set the adapters unless needed if (state.editingMode && chips.adapter == null) chips.adapter = chipsAdapter - if (state.editingMode && contacts.adapter == null) contacts.adapter = contactsAdapter - toolbar.menu.findItem(R.id.call)?.isVisible = !state.editingMode && state.selectedMessages == 0 && state.query.isEmpty() - toolbar.menu.findItem(R.id.info)?.isVisible = !state.editingMode && state.selectedMessages == 0 && state.query.isEmpty() - toolbar.menu.findItem(R.id.copy)?.isVisible = !state.editingMode && state.selectedMessages == 1 + toolbar.menu.findItem(R.id.add)?.isVisible = state.editingMode + toolbar.menu.findItem(R.id.call)?.isVisible = !state.editingMode && state.selectedMessages == 0 + && state.query.isEmpty() + toolbar.menu.findItem(R.id.info)?.isVisible = !state.editingMode && state.selectedMessages == 0 + && state.query.isEmpty() + toolbar.menu.findItem(R.id.copy)?.isVisible = !state.editingMode && state.selectedMessages > 0 toolbar.menu.findItem(R.id.details)?.isVisible = !state.editingMode && state.selectedMessages == 1 toolbar.menu.findItem(R.id.delete)?.isVisible = !state.editingMode && state.selectedMessages > 0 toolbar.menu.findItem(R.id.forward)?.isVisible = !state.editingMode && state.selectedMessages == 1 @@ -227,16 +230,11 @@ class ComposeActivity : QkThemedActivity(), ComposeView { toolbar.menu.findItem(R.id.next)?.isVisible = state.selectedMessages == 0 && state.query.isNotEmpty() toolbar.menu.findItem(R.id.clear)?.isVisible = state.selectedMessages == 0 && state.query.isNotEmpty() - if (chipsAdapter.data.isEmpty() && state.selectedContacts.isNotEmpty()) { - message.showKeyboard() - } - - chipsAdapter.data = state.selectedContacts - contactsAdapter.data = state.contacts + chipsAdapter.data = state.selectedChips loading.setVisible(state.loading) - sendAsGroup.setVisible(state.editingMode && state.selectedContacts.size >= 2) + sendAsGroup.setVisible(state.editingMode && state.selectedChips.size >= 2) sendAsGroupSwitch.isChecked = state.sendAsGroup messageList.setVisible(state.sendAsGroup) @@ -249,7 +247,7 @@ class ComposeActivity : QkThemedActivity(), ComposeView { attachments.setVisible(state.attachments.isNotEmpty()) attachmentAdapter.data = state.attachments - attach.animate().rotation(if (state.attaching) 45f else 0f).start() + attach.animate().rotation(if (state.attaching) 135f else 0f).start() attaching.isVisible = state.attaching counter.text = state.remaining @@ -297,7 +295,8 @@ class ComposeActivity : QkThemedActivity(), ComposeView { calendar.set(Calendar.HOUR_OF_DAY, hour) calendar.set(Calendar.MINUTE, minute) scheduleSelectedIntent.onNext(calendar.timeInMillis) - }, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), DateFormat.is24HourFormat(this)).show() + }, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), DateFormat.is24HourFormat(this)) + .show() }, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH)).show() } @@ -305,7 +304,26 @@ class ComposeActivity : QkThemedActivity(), ComposeView { val intent = Intent(Intent.ACTION_PICK) .setType(ContactsContract.CommonDataKinds.Phone.CONTENT_TYPE) - startActivityForResult(Intent.createChooser(intent, null), CONTACT_REQUEST_CODE) + startActivityForResult(Intent.createChooser(intent, null), AttachContactRequestCode) + } + + override fun showContacts(sharing: Boolean, chips: List) { + message.hideKeyboard() + val serialized = HashMap(chips.associate { chip -> chip.address to chip.contact?.lookupKey }) + val intent = Intent(this, ContactsActivity::class.java) + .putExtra(ContactsActivity.SharingKey, sharing) + .putExtra(ContactsActivity.ChipsKey, serialized) + startActivityForResult(intent, SelectContactRequestCode) + } + + override fun themeChanged() { + messageList.scrapViews() + } + + override fun showKeyboard() { + message.postDelayed({ + message.showKeyboard() + }, 200) } override fun requestCamera() { @@ -315,17 +333,17 @@ class ComposeActivity : QkThemedActivity(), ComposeView { val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) .putExtra(MediaStore.EXTRA_OUTPUT, cameraDestination) - startActivityForResult(Intent.createChooser(intent, null), CAMERA_REQUEST_CODE) + startActivityForResult(Intent.createChooser(intent, null), TakePhotoRequestCode) } override fun requestGallery() { val intent = Intent(Intent.ACTION_PICK) - .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) .putExtra(Intent.EXTRA_LOCAL_ONLY, false) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .setType("image/*") - startActivityForResult(Intent.createChooser(intent, null), GALLERY_REQUEST_CODE) + startActivityForResult(Intent.createChooser(intent, null), AttachPhotoRequestCode) } override fun setDraft(draft: String) = message.setText(draft) @@ -360,15 +378,39 @@ class ComposeActivity : QkThemedActivity(), ComposeView { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (resultCode == Activity.RESULT_OK) { - when (requestCode) { - CAMERA_REQUEST_CODE -> cameraDestination?.let(attachmentSelectedIntent::onNext) - GALLERY_REQUEST_CODE -> data?.data?.let(attachmentSelectedIntent::onNext) - CONTACT_REQUEST_CODE -> data?.data?.let(contactSelectedIntent::onNext) + when { + requestCode == SelectContactRequestCode -> { + chipsSelectedIntent.onNext(data?.getSerializableExtra(ContactsActivity.ChipsKey) + ?.let { serializable -> serializable as? HashMap } + ?: hashMapOf()) + } + requestCode == TakePhotoRequestCode && resultCode == Activity.RESULT_OK -> { + cameraDestination?.let(attachmentSelectedIntent::onNext) + } + requestCode == AttachPhotoRequestCode && resultCode == Activity.RESULT_OK -> { + data?.clipData?.itemCount + ?.let { count -> 0 until count } + ?.mapNotNull { i -> data.clipData?.getItemAt(i)?.uri } + ?.forEach(attachmentSelectedIntent::onNext) + ?: data?.data?.let(attachmentSelectedIntent::onNext) + } + requestCode == AttachContactRequestCode && resultCode == Activity.RESULT_OK -> { + data?.data?.let(contactSelectedIntent::onNext) } + else -> super.onActivityResult(requestCode, resultCode, data) } } + override fun onSaveInstanceState(outState: Bundle) { + outState.putParcelable(CameraDestinationKey, cameraDestination) + super.onSaveInstanceState(outState) + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle?) { + cameraDestination = savedInstanceState?.getParcelable(CameraDestinationKey) + super.onRestoreInstanceState(savedInstanceState) + } + override fun onBackPressed() = backPressedIntent.onNext(Unit) -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt index d9b0504a2e23af97f8cda72f2e7420f2ee0280b8..dcf9af1fb95889b0781c5b48403d23e79af7e6c6 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeActivityModule.kt @@ -42,25 +42,14 @@ class ComposeActivityModule { fun provideThreadId(activity: ComposeActivity): Long = activity.intent.extras?.getLong("threadId") ?: 0L @Provides - @Named("address") - fun provideAddress(activity: ComposeActivity): String { - var address = "" - - activity.intent.data?.let { - val data = it.toString() - address = when { - it.scheme?.startsWith("smsto") == true -> data.replace("smsto:", "") - it.scheme?.startsWith("mmsto") == true -> data.replace("mmsto:", "") - it.scheme?.startsWith("sms") == true -> data.replace("sms:", "") - it.scheme?.startsWith("mms") == true -> 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") - } - - return address + @Named("addresses") + fun provideAddresses(activity: ComposeActivity): List { + return activity.intent + ?.decodedDataString() + ?.substringAfter(':') // Remove scheme + ?.replaceAfter("?", "") // Remove query + ?.split(",") + ?: listOf() } @Provides @@ -68,6 +57,11 @@ class ComposeActivityModule { fun provideSharedText(activity: ComposeActivity): String { return activity.intent.extras?.getString(Intent.EXTRA_TEXT) ?: activity.intent.extras?.getString("sms_body") + ?: activity.intent?.decodedDataString() + ?.substringAfter('?') // Query string + ?.split(',') + ?.firstOrNull { param -> param.startsWith("body") } + ?.substringAfter('=') ?: "" } @@ -85,4 +79,13 @@ class ComposeActivityModule { @ViewModelKey(ComposeViewModel::class) fun provideComposeViewModel(viewModel: ComposeViewModel): ViewModel = viewModel + // The dialer app on Oreo sends a URL encoded string, make sure to decode it + private fun Intent.decodedDataString(): String? { + val data = data?.toString() + if (data?.contains('%') == true) { + return URLDecoder.decode(data, "UTF-8") + } + return data + } + } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt index 29862e2ff4dfa0c8d405fac21a83bcf3851f413e..1524b201870eaa724afab7528a6151624a8b6c68 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeState.kt @@ -20,18 +20,16 @@ package com.moez.QKSMS.feature.compose import com.moez.QKSMS.compat.SubscriptionInfoCompat import com.moez.QKSMS.model.Attachment -import com.moez.QKSMS.model.Contact import com.moez.QKSMS.model.Conversation import com.moez.QKSMS.model.Message +import com.moez.QKSMS.model.Recipient import io.realm.RealmResults data class ComposeState( val hasError: Boolean = false, val editingMode: Boolean = false, - val contacts: List = ArrayList(), - val contactsVisible: Boolean = false, - val selectedConversation: Long = 0, - val selectedContacts: List = ArrayList(), + val threadId: Long = 0, + val selectedChips: List = ArrayList(), val sendAsGroup: Boolean = true, val conversationtitle: String = "", val loading: Boolean = false, diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt index b59321efe21d63fb424b46d5cd45b664210c52bb..65b9fd865cfb78ac5e358b77108252cf4de6b21f 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeView.kt @@ -23,18 +23,15 @@ import androidx.annotation.StringRes import androidx.core.view.inputmethod.InputContentInfoCompat import com.moez.QKSMS.common.base.QkView import com.moez.QKSMS.model.Attachment -import com.moez.QKSMS.model.Contact +import com.moez.QKSMS.model.Recipient import io.reactivex.Observable import io.reactivex.subjects.Subject interface ComposeView : QkView { val activityVisibleIntent: Observable - val queryChangedIntent: Observable - val queryBackspaceIntent: Observable<*> - val queryEditorActionIntent: Observable - val chipSelectedIntent: Subject - val chipDeletedIntent: Subject + val chipsSelectedIntent: Subject> + val chipDeletedIntent: Subject val menuReadyIntent: Observable val optionsItemIntent: Observable val sendAsGroupIntent: Observable<*> @@ -65,6 +62,9 @@ interface ComposeView : QkView { fun requestDefaultSms() fun requestStoragePermission() fun requestSmsPermission() + fun showContacts(sharing: Boolean, chips: List) + fun themeChanged() + fun showKeyboard() fun requestCamera() fun requestGallery() fun requestDatePicker() diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt index d74fc9aa0935e655c18ca8a2a97e865c3cf6e176..b9ed2df0f696939881baa80e62036f6443b44b22 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeViewModel.kt @@ -30,6 +30,7 @@ import androidx.annotation.RequiresApi import com.moez.QKSMS.R import com.moez.QKSMS.common.Navigator import com.moez.QKSMS.common.base.QkViewModel +import com.moez.QKSMS.common.util.BillingManager import com.moez.QKSMS.common.util.ClipboardUtils import com.moez.QKSMS.common.util.MessageDetailsFormatter import com.moez.QKSMS.common.util.extensions.makeToast @@ -39,12 +40,19 @@ import com.moez.QKSMS.extensions.asObservable import com.moez.QKSMS.extensions.isImage import com.moez.QKSMS.extensions.isVideo import com.moez.QKSMS.extensions.mapNotNull -import com.moez.QKSMS.extensions.removeAccents -import com.moez.QKSMS.filter.ContactFilter -import com.moez.QKSMS.interactor.* +import com.moez.QKSMS.interactor.AddScheduledMessage +import com.moez.QKSMS.interactor.CancelDelayedMessage +import com.moez.QKSMS.interactor.DeleteMessages +import com.moez.QKSMS.interactor.MarkRead +import com.moez.QKSMS.interactor.RetrySending +import com.moez.QKSMS.interactor.SendMessage import com.moez.QKSMS.manager.ActiveConversationManager import com.moez.QKSMS.manager.PermissionManager -import com.moez.QKSMS.model.* +import com.moez.QKSMS.model.Attachment +import com.moez.QKSMS.model.Attachments +import com.moez.QKSMS.model.Conversation +import com.moez.QKSMS.model.Message +import com.moez.QKSMS.model.Recipient import com.moez.QKSMS.repository.ContactRepository import com.moez.QKSMS.repository.ConversationRepository import com.moez.QKSMS.repository.MessageRepository @@ -63,7 +71,6 @@ 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 timber.log.Timber import java.util.* import javax.inject.Inject @@ -72,15 +79,15 @@ import javax.inject.Named class ComposeViewModel @Inject constructor( @Named("query") private val query: String, @Named("threadId") private val threadId: Long, - @Named("address") private val address: String, + @Named("addresses") private val addresses: List, @Named("text") private val sharedText: String, @Named("attachments") private val sharedAttachments: Attachments, + private val contactRepo: ContactRepository, private val context: Context, private val activeConversationManager: ActiveConversationManager, private val addScheduledMessage: AddScheduledMessage, + private val billingManager: BillingManager, private val cancelMessage: CancelDelayedMessage, - private val contactFilter: ContactFilter, - private val contactsRepo: ContactRepository, private val conversationRepo: ConversationRepository, private val deleteMessages: DeleteMessages, private val markRead: MarkRead, @@ -92,22 +99,22 @@ class ComposeViewModel @Inject constructor( private val prefs: Preferences, private val retrySending: RetrySending, private val sendMessage: SendMessage, - private val subscriptionManager: SubscriptionManagerCompat, - private val syncContacts: ContactSync + private val subscriptionManager: SubscriptionManagerCompat ) : QkViewModel(ComposeState( - editingMode = threadId == 0L && address.isBlank(), - selectedConversation = threadId, + editingMode = threadId == 0L && addresses.isEmpty(), + threadId = threadId, query = query) ) { private val attachments: Subject> = BehaviorSubject.createDefault(sharedAttachments) - private val contacts: Observable> by lazy { contactsRepo.getUnmanagedContacts().toObservable() } - private val contactsReducer: Subject<(List) -> List> = PublishSubject.create() - private val selectedContacts: Subject> = BehaviorSubject.createDefault(listOf()) - private val searchResults: Subject> = BehaviorSubject.create() - private val searchSelection: Subject = BehaviorSubject.createDefault(-1) + private val chipsReducer: Subject<(List) -> List> = PublishSubject.create() private val conversation: Subject = BehaviorSubject.create() private val messages: Subject> = BehaviorSubject.create() + private val selectedChips: Subject> = BehaviorSubject.createDefault(listOf()) + private val searchResults: Subject> = BehaviorSubject.create() + private val searchSelection: Subject = BehaviorSubject.createDefault(-1) + + private var shouldShowContacts = threadId == 0L && addresses.isEmpty() init { val initialConversation = threadId.takeIf { it != 0L } @@ -115,9 +122,9 @@ class ComposeViewModel @Inject constructor( ?.asObservable() ?: Observable.empty() - val selectedConversation = selectedContacts + val selectedConversation = selectedChips .skipWhile { it.isEmpty() } - .map { contacts -> contacts.map { it.numbers.firstOrNull()?.address ?: "" } } + .map { chips -> chips.map { it.address } } .distinctUntilChanged() .doOnNext { newState { copy(loading = true) } } .observeOn(Schedulers.io()) @@ -158,24 +165,24 @@ class ComposeViewModel @Inject constructor( .filter { conversation -> conversation.isValid } .subscribe(conversation::onNext) - if (address.isNotBlank()) { - selectedContacts.onNext(listOf(Contact(numbers = RealmList(PhoneNumber(address))))) + if (addresses.isNotEmpty()) { + selectedChips.onNext(addresses.map { address -> Recipient(address = address) }) } - disposables += contactsReducer - .scan(listOf()) { previousState, reducer -> reducer(previousState) } - .doOnNext { contacts -> newState { copy(selectedContacts = contacts) } } + disposables += chipsReducer + .scan(listOf()) { previousState, reducer -> reducer(previousState) } + .doOnNext { chips -> newState { copy(selectedChips = chips) } } .skipUntil(state.filter { state -> state.editingMode }) .takeUntil(state.filter { state -> !state.editingMode }) - .subscribe(selectedContacts::onNext) + .subscribe(selectedChips::onNext) - // When the conversation changes, mark read, and update the threadId and the messages for the adapter + // When the conversation changes, mark read, and update the recipientId 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)) } + newState { copy(threadId = conversation.id, messages = Pair(conversation, messages)) } messages } .switchMap { messages -> messages.asObservable() } @@ -217,92 +224,70 @@ class ComposeViewModel @Inject constructor( 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) - } } @RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1) 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) } } + val sharing = sharedText.isNotEmpty() || sharedAttachments.isNotEmpty() + if (shouldShowContacts) { + shouldShowContacts = false + view.showContacts(sharing, selectedChips.blockingFirst()) + } - // 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.isPossibleNumber(query.toString())) { - val newAddress = phoneNumberUtils.formatNumber(query) - val newContact = Contact(numbers = RealmList(PhoneNumber(address = newAddress))) - filteredContacts = listOf(newContact) + filteredContacts + view.chipsSelectedIntent + .withLatestFrom(selectedChips) { hashmap, chips -> + // If there's no contacts already selected, and the user cancelled the contact + // selection, close the activity + if (hashmap.isEmpty() && chips.isEmpty()) { + newState { copy(hasError = true) } + } + // Filter out any numbers that are already selected + hashmap.filter { (address) -> + chips.none { recipient -> phoneNumberUtils.compare(address, recipient.address) } + } + } + .filter { hashmap -> hashmap.isNotEmpty() } + .map { hashmap -> + hashmap.map { (address, lookupKey) -> + conversationRepo.getRecipients() + .asSequence() + .filter { recipient -> recipient.contact?.lookupKey == lookupKey } + .firstOrNull { recipient -> phoneNumberUtils.compare(recipient.address, address) } + ?: Recipient( + address = address, + contact = lookupKey?.let(contactRepo::getUnmanagedContact)) } - - 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) } } + .subscribe { chips -> + chipsReducer.onNext { list -> list + chips } + view.showKeyboard() + } - // 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) } - } + // Set the contact suggestions list to visible when the add button is pressed + view.optionsItemIntent + .filter { it == R.id.add } + .withLatestFrom(selectedChips) { _, chips -> + view.showContacts(sharing, chips) } .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 } + // Update the list of selected contacts when a new contact is selected or an existing one is deselected + view.chipDeletedIntent .autoDisposable(view.scope()) - .subscribe { state -> - state.contacts.firstOrNull()?.let { contact -> - contactsReducer.onNext { contacts -> contacts + contact } + .subscribe { contact -> + chipsReducer.onNext { contacts -> + val result = contacts.filterNot { it == contact } + if (result.isEmpty()) { + view.showContacts(sharing, result) + } + result } } - // 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()) @@ -327,11 +312,20 @@ class ComposeViewModel @Inject constructor( // 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) + .withLatestFrom(view.messagesSelectedIntent) { _, messageIds -> + val messages = messageIds.mapNotNull(messageRepo::getMessage).sortedBy { it.date } + val text = when (messages.size) { + 1 -> messages.first().getText() + else -> messages.foldIndexed("") { index, acc, message -> + when { + index == 0 -> message.getText() + messages[index - 1].compareSender(message) -> "$acc\n${message.getText()}" + else -> "$acc\n\n${message.getText()}" + } + } } + + ClipboardUtils.copy(context, text) } .autoDisposable(view.scope()) .subscribe { view.clearSelection() } @@ -410,6 +404,13 @@ class ComposeViewModel @Inject constructor( .autoDisposable(view.scope()) .subscribe(view::scrollToMessage) + // Theme changes + prefs.keyChanges + .filter { key -> key.contains("theme") } + .doOnNext { view.themeChanged() } + .autoDisposable(view.scope()) + .subscribe() + // Retry sending view.messageClickIntent .mapNotNull(messageRepo::getMessage) @@ -452,21 +453,22 @@ class ComposeViewModel @Inject constructor( .subscribe { message -> cancelMessage.execute(message.id) } // Set the current conversation - Observables.combineLatest( - view.activityVisibleIntent.distinctUntilChanged(), - conversation.mapNotNull { conversation -> - conversation.takeIf { it.isValid }?.id - }.distinctUntilChanged()) - { visible, threadId -> - when (visible) { - true -> { - activeConversationManager.setActiveConversation(threadId) - markRead.execute(listOf(threadId)) - } + Observables + .combineLatest( + view.activityVisibleIntent.distinctUntilChanged(), + conversation.mapNotNull { conversation -> + conversation.takeIf { it.isValid }?.id + }.distinctUntilChanged()) + { visible, threadId -> + when (visible) { + true -> { + activeConversationManager.setActiveConversation(threadId) + markRead.execute(listOf(threadId)) + } - false -> activeConversationManager.setActiveConversation(null) - } - } + false -> activeConversationManager.setActiveConversation(null) + } + } .autoDisposable(view.scope()) .subscribe() @@ -516,7 +518,7 @@ class ComposeViewModel @Inject constructor( view.attachmentSelectedIntent.map { uri -> Attachment.Image(uri) }, view.inputContentIntent.map { inputContent -> Attachment.Image(inputContent = inputContent) }) .withLatestFrom(attachments) { attachment, attachments -> attachments + attachment } - .doOnNext { attachments.onNext(it) } + .doOnNext(attachments::onNext) .autoDisposable(view.scope()) .subscribe { newState { copy(attaching = false) } } @@ -638,12 +640,12 @@ class ComposeViewModel @Inject constructor( .filter { permissionManager.hasSendSms().also { if (!it) view.requestSmsPermission() } } .withLatestFrom(view.textChangedIntent) { _, body -> body } .map { body -> body.toString() } - .withLatestFrom(state, attachments, conversation, selectedContacts) { body, state, attachments, - conversation, contacts -> + .withLatestFrom(state, attachments, conversation, selectedChips) { body, state, attachments, + conversation, chips -> val subId = state.subscription?.subscriptionId ?: -1 val addresses = when (conversation.recipients.isNotEmpty()) { true -> conversation.recipients.map { it.address } - false -> contacts.mapNotNull { it.numbers.firstOrNull()?.address } + false -> chips.map { chip -> chip.address } } val delay = when (prefs.sendDelay.get()) { Preferences.SEND_DELAY_SHORT -> 3000 diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeWindowCallback.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeWindowCallback.kt index b3f8acbfe30264de2a3c09689740595ca4586137..0ea9eab794d7e0b4df3aa83e1fac5ca8580721e7 100755 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeWindowCallback.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ComposeWindowCallback.kt @@ -32,8 +32,12 @@ import android.view.Window import android.view.WindowManager import android.view.accessibility.AccessibilityEvent import androidx.annotation.RequiresApi +import com.moez.QKSMS.feature.compose.editing.DetailedChipView -class ComposeWindowCallback(private val localCallback: Window.Callback, private val activity: Activity) : Window.Callback { +class ComposeWindowCallback( + private val localCallback: Window.Callback, + private val activity: Activity +) : Window.Callback { override fun dispatchKeyEvent(keyEvent: KeyEvent): Boolean { return localCallback.dispatchKeyEvent(keyEvent) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ContactAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/ContactAdapter.kt deleted file mode 100644 index 524bb783c19258c4dffe8d5f272f27faf6330c7c..0000000000000000000000000000000000000000 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/ContactAdapter.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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 com.moez.QKSMS.feature.compose - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.moez.QKSMS.R -import com.moez.QKSMS.common.base.QkAdapter -import com.moez.QKSMS.common.base.QkViewHolder -import com.moez.QKSMS.common.util.extensions.setVisible -import com.moez.QKSMS.model.Contact -import io.reactivex.subjects.PublishSubject -import io.reactivex.subjects.Subject -import kotlinx.android.synthetic.main.contact_list_item.view.* -import javax.inject.Inject - -class ContactAdapter @Inject constructor() : QkAdapter() { - - val contactSelected: Subject = PublishSubject.create() - - private val numbersViewPool = RecyclerView.RecycledViewPool() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val view = layoutInflater.inflate(R.layout.contact_list_item, parent, false) - - view.addresses.setRecycledViewPool(numbersViewPool) - - return QkViewHolder(view).apply { - view.primary.setOnClickListener { - val contact = getItem(adapterPosition) - contactSelected.onNext(copyContact(contact, 0)) - } - - view.addresses.adapter = PhoneNumberAdapter { contact, index -> - contactSelected.onNext(copyContact(contact, index + 1)) - } - } - } - - override fun onBindViewHolder(holder: QkViewHolder, position: Int) { - val contact = getItem(position) - val view = holder.containerView - - view.avatar.setContact(contact) - view.name.text = contact.name - view.name.setVisible(view.name.text.isNotEmpty()) - view.address.text = contact.numbers.firstOrNull()?.address ?: "" - view.type.text = contact.numbers.firstOrNull()?.type ?: "" - - val adapter = view.addresses.adapter as PhoneNumberAdapter - adapter.contact = contact - adapter.data = contact.numbers.drop(Math.min(contact.numbers.size, 1)) - } - - /** - * Creates a copy of the contact with only one phone number, so that the chips - * view can still display the name/photo, and not get confused about which phone number to use - */ - private fun copyContact(contact: Contact, numberIndex: Int) = Contact().apply { - lookupKey = contact.lookupKey - name = contact.name - numbers.add(contact.numbers[numberIndex]) - } - - override fun areItemsTheSame(old: Contact, new: Contact): Boolean { - return old.lookupKey == new.lookupKey - } - -} \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt index 9443fb340e29196663157d7d34279180c31ee070..f11b33983c61db78d91db7e18d43fa91bfd74bb1 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt @@ -59,7 +59,15 @@ import com.moez.QKSMS.util.Preferences import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject import io.realm.RealmResults +import kotlinx.android.synthetic.main.message_list_item_in.* +import kotlinx.android.synthetic.main.message_list_item_in.attachments +import kotlinx.android.synthetic.main.message_list_item_in.body +import kotlinx.android.synthetic.main.message_list_item_in.sim +import kotlinx.android.synthetic.main.message_list_item_in.simIndex +import kotlinx.android.synthetic.main.message_list_item_in.status +import kotlinx.android.synthetic.main.message_list_item_in.timestamp import kotlinx.android.synthetic.main.message_list_item_in.view.* +import kotlinx.android.synthetic.main.message_list_item_out.* import java.util.* import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -98,9 +106,6 @@ class MessagesAdapter @Inject constructor( field = value contactCache.clear() - // Update the theme - theme = colors.theme(value?.first?.id ?: 0) - updateData(value?.second) } @@ -145,9 +150,6 @@ class MessagesAdapter @Inject constructor( view.findViewById(R.id.cancel).setTint(theme.theme) } else { view = layoutInflater.inflate(R.layout.message_list_item_in, parent, false) - view.avatar.threadId = conversation?.id ?: 0 - view.body.setTextColor(theme.textPrimary) - view.body.setBackgroundTint(theme.theme) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -181,17 +183,21 @@ class MessagesAdapter @Inject constructor( } } - override fun onBindViewHolder(viewHolder: QkViewHolder, position: Int) { + override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val message = getItem(position) ?: return val previous = if (position == 0) null else getItem(position - 1) val next = if (position == itemCount - 1) null else getItem(position + 1) - val view = viewHolder.containerView + + val theme = when (message.isOutgoingMessage()) { + true -> colors.theme() + false -> colors.theme(contactCache[message.address]) + } // Update the selected state - view.isActivated = isSelected(message.id) || highlight == message.id + holder.containerView.isActivated = isSelected(message.id) || highlight == message.id // Bind the cancel view - view.findViewById(R.id.cancel)?.let { cancel -> + holder.cancel?.let { cancel -> val isCancellable = message.isSending() && message.date > System.currentTimeMillis() cancel.setVisible(isCancellable) cancel.clicks().subscribe { cancelSending.onNext(message.id) } @@ -213,29 +219,31 @@ class MessagesAdapter @Inject constructor( } // Bind the message status - bindStatus(viewHolder, message, next) + bindStatus(holder, message, next) // Bind the timestamp val timeSincePrevious = TimeUnit.MILLISECONDS.toMinutes(message.date - (previous?.date ?: 0)) val simIndex = subs.takeIf { it.size > 1 }?.indexOfFirst { it.subscriptionId == message.subId } ?: -1 - view.timestamp.text = dateFormatter.getMessageTimestamp(message.date) - view.simIndex.text = "${simIndex + 1}" + holder.timestamp.text = dateFormatter.getMessageTimestamp(message.date) + holder.simIndex.text = "${simIndex + 1}" - view.timestamp.setVisible(timeSincePrevious >= BubbleUtils.TIMESTAMP_THRESHOLD + holder.timestamp.setVisible(timeSincePrevious >= BubbleUtils.TIMESTAMP_THRESHOLD || message.subId != previous?.subId && simIndex != -1) - view.sim.setVisible(message.subId != previous?.subId && simIndex != -1) - view.simIndex.setVisible(message.subId != previous?.subId && simIndex != -1) + holder.sim.setVisible(message.subId != previous?.subId && simIndex != -1) + holder.simIndex.setVisible(message.subId != previous?.subId && simIndex != -1) // Bind the grouping val media = message.parts.filter { !it.isSmil() && !it.isText() } - view.setPadding(bottom = if (canGroup(message, next)) 0 else 16.dpToPx(context)) + holder.containerView.setPadding(bottom = if (canGroup(message, next)) 0 else 16.dpToPx(context)) - // Bind the avatar + // Bind the avatar and bubble colour if (!message.isMe()) { - view.avatar.threadId = conversation?.id ?: 0 - view.avatar.setContact(contactCache[message.address]) - view.avatar.setVisible(!canGroup(message, next), View.INVISIBLE) + holder.avatar.setRecipient(contactCache[message.address]) + holder.avatar.setVisible(!canGroup(message, next), View.INVISIBLE) + + holder.body.setTextColor(theme.textPrimary) + holder.body.setBackgroundTint(theme.theme) } // Bind the body text @@ -261,31 +269,29 @@ class MessagesAdapter @Inject constructor( } } val emojiOnly = messageText.isNotBlank() && messageText.matches(EMOJI_REGEX) - textViewStyler.setTextSize(view.body, when (emojiOnly) { + textViewStyler.setTextSize(holder.body, when (emojiOnly) { true -> TextViewStyler.SIZE_EMOJI false -> TextViewStyler.SIZE_PRIMARY }) - view.body.text = messageText - view.body.setVisible(message.isSms() || messageText.isNotBlank()) - view.body.setBackgroundResource(getBubble( + holder.body.text = messageText + holder.body.setVisible(message.isSms() || messageText.isNotBlank()) + holder.body.setBackgroundResource(getBubble( emojiOnly = emojiOnly, canGroupWithPrevious = canGroup(message, previous) || media.isNotEmpty(), canGroupWithNext = canGroup(message, next), isMe = message.isMe())) // Bind the attachments - val partsAdapter = view.attachments.adapter as PartsAdapter + val partsAdapter = holder.attachments.adapter as PartsAdapter partsAdapter.theme = theme - partsAdapter.setData(message, previous, next, view) + partsAdapter.setData(message, previous, next, holder) } - private fun bindStatus(viewHolder: QkViewHolder, message: Message, next: Message?) { - val view = viewHolder.containerView - + private fun bindStatus(holder: QkViewHolder, message: Message, next: Message?) { val age = TimeUnit.MILLISECONDS.toMinutes(System.currentTimeMillis() - message.date) - view.status.text = when { + holder.status.text = when { message.isSending() -> context.getString(R.string.message_status_sending) message.isDelivered() -> context.getString(R.string.message_status_delivered, dateFormatter.getTimestamp(message.dateSent)) @@ -299,7 +305,7 @@ class MessagesAdapter @Inject constructor( else -> dateFormatter.getTimestamp(message.date) } - view.status.setVisible(when { + holder.status.setVisible(when { expanded[message.id] == true -> true message.isSending() -> true message.isFailedMessage() -> true diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/ChipsAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/ChipsAdapter.kt new file mode 100755 index 0000000000000000000000000000000000000000..b415d32f377e8583b322ee85a35c9423b690f635 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/ChipsAdapter.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.compose.editing + +import android.content.Context +import android.os.Build +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.RelativeLayout +import androidx.recyclerview.widget.RecyclerView +import com.moez.QKSMS.R +import com.moez.QKSMS.common.base.QkAdapter +import com.moez.QKSMS.common.base.QkViewHolder +import com.moez.QKSMS.common.util.extensions.dpToPx +import com.moez.QKSMS.common.util.extensions.resolveThemeColor +import com.moez.QKSMS.common.util.extensions.setBackgroundTint +import com.moez.QKSMS.model.Recipient +import io.reactivex.subjects.PublishSubject +import kotlinx.android.synthetic.main.contact_chip.* +import javax.inject.Inject + +class ChipsAdapter @Inject constructor() : QkAdapter() { + + var view: RecyclerView? = null + val chipDeleted: PublishSubject = PublishSubject.create() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { + val inflater = LayoutInflater.from(parent.context) + val view = inflater.inflate(R.layout.contact_chip, parent, false) + return QkViewHolder(view).apply { + // These theme attributes don't apply themselves on API 21 + if (Build.VERSION.SDK_INT <= 22) { + content.setBackgroundTint(view.context.resolveThemeColor(R.attr.bubbleColor)) + } + + view.setOnClickListener { + val chip = getItem(adapterPosition) + showDetailedChip(view.context, chip) + } + } + } + + override fun onBindViewHolder(holder: QkViewHolder, position: Int) { + val recipient = getItem(position) + + holder.avatar.setRecipient(recipient) + holder.name.text = recipient.contact?.name?.takeIf { it.isNotBlank() } ?: recipient.address + } + + /** + * The [context] has to come from a view, because we're inflating a view that used themed attrs + */ + private fun showDetailedChip(context: Context, recipient: Recipient) { + val detailedChipView = DetailedChipView(context) + detailedChipView.setRecipient(recipient) + + val rootView = view?.rootView as ViewGroup + + val layoutParams = RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT) + + layoutParams.topMargin = 24.dpToPx(context) + layoutParams.marginStart = 56.dpToPx(context) + + rootView.addView(detailedChipView, layoutParams) + detailedChipView.show() + + detailedChipView.setOnDeleteListener { + chipDeleted.onNext(recipient) + detailedChipView.hide() + } + } +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/ComposeItem.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/ComposeItem.kt new file mode 100644 index 0000000000000000000000000000000000000000..421418bf8fd280b09b62d5eac80f3c2366a99848 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/ComposeItem.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.compose.editing + +import com.moez.QKSMS.model.Contact +import com.moez.QKSMS.model.ContactGroup +import com.moez.QKSMS.model.Conversation +import com.moez.QKSMS.model.PhoneNumber +import io.realm.RealmList + +sealed class ComposeItem { + + abstract fun getContacts(): List + + data class New(val value: Contact) : ComposeItem() { + override fun getContacts(): List = listOf(value) + } + + data class Recent(val value: Conversation) : ComposeItem() { + override fun getContacts(): List = value.recipients.map { recipient -> + recipient.contact ?: Contact(numbers = RealmList(PhoneNumber(address = recipient.address))) + } + } + + data class Starred(val value: Contact) : ComposeItem() { + override fun getContacts(): List = listOf(value) + } + + data class Group(val value: ContactGroup) : ComposeItem() { + override fun getContacts(): List = value.contacts + } + + data class Person(val value: Contact) : ComposeItem() { + override fun getContacts(): List = listOf(value) + } +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/ComposeItemAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/ComposeItemAdapter.kt new file mode 100644 index 0000000000000000000000000000000000000000..3acb379eb3ed88c9f103a62384f17bf18a2f41df --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/ComposeItemAdapter.kt @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.compose.editing + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.moez.QKSMS.R +import com.moez.QKSMS.common.base.QkAdapter +import com.moez.QKSMS.common.base.QkViewHolder +import com.moez.QKSMS.common.util.Colors +import com.moez.QKSMS.common.util.extensions.forwardTouches +import com.moez.QKSMS.common.util.extensions.setTint +import com.moez.QKSMS.extensions.associateByNotNull +import com.moez.QKSMS.model.Contact +import com.moez.QKSMS.model.ContactGroup +import com.moez.QKSMS.model.Conversation +import com.moez.QKSMS.model.Recipient +import com.moez.QKSMS.repository.ConversationRepository +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.plusAssign +import io.reactivex.subjects.PublishSubject +import io.reactivex.subjects.Subject +import kotlinx.android.synthetic.main.contact_list_item.* +import kotlinx.android.synthetic.main.contact_list_item.view.* +import javax.inject.Inject + +class ComposeItemAdapter @Inject constructor( + private val colors: Colors, + private val conversationRepo: ConversationRepository +) : QkAdapter() { + + val clicks: Subject = PublishSubject.create() + val longClicks: Subject = PublishSubject.create() + + private val numbersViewPool = RecyclerView.RecycledViewPool() + private val disposables = CompositeDisposable() + + var recipients: Map = mapOf() + set(value) { + field = value + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val view = layoutInflater.inflate(R.layout.contact_list_item, parent, false) + + view.icon.setTint(colors.theme().theme) + + view.numbers.setRecycledViewPool(numbersViewPool) + view.numbers.adapter = PhoneNumberAdapter() + view.numbers.forwardTouches(view) + + return QkViewHolder(view).apply { + view.setOnClickListener { + val item = getItem(adapterPosition) + clicks.onNext(item) + } + view.setOnLongClickListener { + val item = getItem(adapterPosition) + longClicks.onNext(item) + true + } + } + } + + override fun onBindViewHolder(holder: QkViewHolder, position: Int) { + val prevItem = if (position > 0) getItem(position - 1) else null + val item = getItem(position) + + when (item) { + is ComposeItem.New -> bindNew(holder, item.value) + is ComposeItem.Recent -> bindRecent(holder, item.value, prevItem) + is ComposeItem.Starred -> bindStarred(holder, item.value, prevItem) + is ComposeItem.Person -> bindPerson(holder, item.value, prevItem) + is ComposeItem.Group -> bindGroup(holder, item.value, prevItem) + } + } + + private fun bindNew(holder: QkViewHolder, contact: Contact) { + holder.index.isVisible = false + + holder.icon.isVisible = false + + holder.avatar.recipients = listOf(createRecipient(contact)) + + holder.title.text = contact.numbers.joinToString { it.address } + + holder.subtitle.isVisible = false + + holder.numbers.isVisible = false + } + + private fun bindRecent(holder: QkViewHolder, conversation: Conversation, prev: ComposeItem?) { + holder.index.isVisible = false + + holder.icon.isVisible = prev !is ComposeItem.Recent + holder.icon.setImageResource(R.drawable.ic_history_black_24dp) + + holder.avatar.recipients = conversation.recipients + + holder.title.text = conversation.getTitle() + + holder.subtitle.isVisible = conversation.recipients.size > 1 && conversation.name.isBlank() + holder.subtitle.text = conversation.recipients.joinToString(", ") { recipient -> + recipient.contact?.name ?: recipient.address + } + + holder.numbers.isVisible = conversation.recipients.size == 1 + (holder.numbers.adapter as PhoneNumberAdapter).data = conversation.recipients + .mapNotNull { recipient -> recipient.contact } + .flatMap { contact -> contact.numbers } + } + + private fun bindStarred(holder: QkViewHolder, contact: Contact, prev: ComposeItem?) { + holder.index.isVisible = false + + holder.icon.isVisible = prev !is ComposeItem.Starred + holder.icon.setImageResource(R.drawable.ic_star_black_24dp) + + holder.avatar.recipients = listOf(createRecipient(contact)) + + holder.title.text = contact.name + + holder.subtitle.isVisible = false + + holder.numbers.isVisible = true + (holder.numbers.adapter as PhoneNumberAdapter).data = contact.numbers + } + + private fun bindGroup(holder: QkViewHolder, group: ContactGroup, prev: ComposeItem?) { + holder.index.isVisible = false + + holder.icon.isVisible = prev !is ComposeItem.Group + holder.icon.setImageResource(R.drawable.ic_people_black_24dp) + + holder.avatar.recipients = group.contacts.map(::createRecipient) + + holder.title.text = group.title + + holder.subtitle.isVisible = true + holder.subtitle.text = group.contacts.joinToString(", ") { it.name } + + holder.numbers.isVisible = false + } + + private fun bindPerson(holder: QkViewHolder, contact: Contact, prev: ComposeItem?) { + holder.index.isVisible = true + holder.index.text = if (contact.name.getOrNull(0)?.isLetter() == true) contact.name[0].toString() else "#" + holder.index.isVisible = prev !is ComposeItem.Person || + (contact.name[0].isLetter() && !contact.name[0].equals(prev.value.name[0], ignoreCase = true)) || + (!contact.name[0].isLetter() && prev.value.name[0].isLetter()) + + holder.icon.isVisible = false + + holder.avatar.recipients = listOf(createRecipient(contact)) + + holder.title.text = contact.name + + holder.subtitle.isVisible = false + + holder.numbers.isVisible = true + (holder.numbers.adapter as PhoneNumberAdapter).data = contact.numbers + } + + private fun createRecipient(contact: Contact): Recipient { + return recipients[contact.lookupKey] ?: Recipient( + address = contact.numbers.firstOrNull()?.address ?: "", + contact = contact) + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + disposables += conversationRepo.getUnmanagedRecipients() + .map { recipients -> recipients.associateByNotNull { recipient -> recipient.contact?.lookupKey } } + .subscribe { recipients -> this@ComposeItemAdapter.recipients = recipients } + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + disposables.clear() + } + + override fun areItemsTheSame(old: ComposeItem, new: ComposeItem): Boolean { + val oldIds = old.getContacts().map { contact -> contact.lookupKey } + val newIds = new.getContacts().map { contact -> contact.lookupKey } + return oldIds == newIds + } + + override fun areContentsTheSame(old: ComposeItem, new: ComposeItem): Boolean { + return false + } + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/DetailedChipView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/DetailedChipView.kt similarity index 84% rename from presentation/src/main/java/com/moez/QKSMS/feature/compose/DetailedChipView.kt rename to presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/DetailedChipView.kt index 5bcca0daee61936ff32a7126efa628de96122894..aa90cd9946255300c2f9bc25f24521a0ec11a19a 100755 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/DetailedChipView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/DetailedChipView.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017 Moez Bhatti + * Copyright (C) 2019 Moez Bhatti * * This file is part of QKSMS. * @@ -16,7 +16,7 @@ * You should have received a copy of the GNU General Public License * along with QKSMS. If not, see . */ -package com.moez.QKSMS.feature.compose +package com.moez.QKSMS.feature.compose.editing import android.content.Context import android.view.View @@ -27,11 +27,10 @@ import com.moez.QKSMS.common.util.Colors import com.moez.QKSMS.common.util.extensions.setBackgroundTint import com.moez.QKSMS.common.util.extensions.setTint import com.moez.QKSMS.injection.appComponent -import com.moez.QKSMS.model.Contact +import com.moez.QKSMS.model.Recipient import kotlinx.android.synthetic.main.contact_chip_detailed.view.* import javax.inject.Inject - class DetailedChipView(context: Context) : RelativeLayout(context) { @Inject lateinit var colors: Colors @@ -46,8 +45,14 @@ class DetailedChipView(context: Context) : RelativeLayout(context) { isFocusable = true isFocusableInTouchMode = true + } + + fun setRecipient(recipient: Recipient) { + avatar.setRecipient(recipient) + name.text = recipient.contact?.name?.takeIf { it.isNotBlank() } ?: recipient.address + info.text = recipient.address - colors.theme().let { theme -> + colors.theme(recipient).let { theme -> card.setBackgroundTint(theme.theme) name.setTextColor(theme.textPrimary) info.setTextColor(theme.textTertiary) @@ -55,12 +60,6 @@ class DetailedChipView(context: Context) : RelativeLayout(context) { } } - fun setContact(contact: Contact) { - avatar.setContact(contact) - name.text = contact.name - info.text = contact.numbers.joinToString(", ") { it.address } - } - fun show() { startAnimation(AlphaAnimation(0f, 1f).apply { duration = 200 }) diff --git a/domain/src/main/java/com/moez/QKSMS/repository/ImageRepository.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/PhoneNumberAction.kt similarity index 73% rename from domain/src/main/java/com/moez/QKSMS/repository/ImageRepository.kt rename to presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/PhoneNumberAction.kt index da02a1f8bdf2e4923f1d82d3e89109bcdc03770c..7464cefd306b82b7aec3f5955a72b51f82d2ee3f 100644 --- a/domain/src/main/java/com/moez/QKSMS/repository/ImageRepository.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/PhoneNumberAction.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017 Moez Bhatti + * Copyright (C) 2019 Moez Bhatti * * This file is part of QKSMS. * @@ -16,13 +16,10 @@ * You should have received a copy of the GNU General Public License * along with QKSMS. If not, see . */ -package com.moez.QKSMS.repository - -import android.graphics.Bitmap -import android.net.Uri - -interface ImageRepository { - - fun loadImage(uri: Uri, width: Int, height: Int): Bitmap? +package com.moez.QKSMS.feature.compose.editing +enum class PhoneNumberAction { + CANCEL, + JUST_ONCE, + ALWAYS } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/PhoneNumberAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/PhoneNumberAdapter.kt similarity index 68% rename from presentation/src/main/java/com/moez/QKSMS/feature/compose/PhoneNumberAdapter.kt rename to presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/PhoneNumberAdapter.kt index 3e8689434fa3864e1eeb2b667d60a4297096977e..534f013e95ca245fe344eb147c67d7cd80d2e213 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/PhoneNumberAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/PhoneNumberAdapter.kt @@ -16,22 +16,17 @@ * You should have received a copy of the GNU General Public License * along with QKSMS. If not, see . */ -package com.moez.QKSMS.feature.compose +package com.moez.QKSMS.feature.compose.editing import android.view.LayoutInflater import android.view.ViewGroup import com.moez.QKSMS.R import com.moez.QKSMS.common.base.QkAdapter import com.moez.QKSMS.common.base.QkViewHolder -import com.moez.QKSMS.model.Contact import com.moez.QKSMS.model.PhoneNumber -import kotlinx.android.synthetic.main.contact_list_item.view.* +import kotlinx.android.synthetic.main.contact_number_list_item.* -class PhoneNumberAdapter( - private val numberClicked: (Contact, Int) -> Unit -) : QkAdapter() { - - lateinit var contact: Contact +class PhoneNumberAdapter : QkAdapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { val inflater = LayoutInflater.from(parent.context) @@ -41,14 +36,17 @@ class PhoneNumberAdapter( override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val number = getItem(position) - val view = holder.containerView - // Setting this in onCreateViewHolder causes a crash sometimes. [contact] returns the - // contact from a different row, I'm not sure why - view.setOnClickListener { numberClicked(contact, position) } + holder.address.text = number.address + holder.type.text = number.type + } + + override fun areItemsTheSame(old: PhoneNumber, new: PhoneNumber): Boolean { + return old.type == new.type && old.address == new.address + } - view.address.text = number.address - view.type.text = number.type + override fun areContentsTheSame(old: PhoneNumber, new: PhoneNumber): Boolean { + return old.type == new.type && old.address == new.address } } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/PhoneNumberPickerAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/PhoneNumberPickerAdapter.kt new file mode 100644 index 0000000000000000000000000000000000000000..3464241f724e28a1b0cd84157f2b1b9a8a0a733c --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/PhoneNumberPickerAdapter.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.compose.editing + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import com.moez.QKSMS.R +import com.moez.QKSMS.common.base.QkAdapter +import com.moez.QKSMS.common.base.QkViewHolder +import com.moez.QKSMS.extensions.Optional +import com.moez.QKSMS.model.PhoneNumber +import io.reactivex.subjects.BehaviorSubject +import io.reactivex.subjects.Subject +import kotlinx.android.synthetic.main.phone_number_list_item.view.* +import kotlinx.android.synthetic.main.radio_preference_view.view.* +import javax.inject.Inject + +class PhoneNumberPickerAdapter @Inject constructor( + private val context: Context +) : QkAdapter() { + + val selectedItemChanges: Subject> = BehaviorSubject.create() + + private var selectedItem: Long? = null + set(value) { + data.indexOfFirst { number -> number.id == field }.takeIf { it != -1 }?.run(::notifyItemChanged) + field = value + data.indexOfFirst { number -> number.id == field }.takeIf { it != -1 }?.run(::notifyItemChanged) + selectedItemChanges.onNext(Optional(value)) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { + val inflater = LayoutInflater.from(parent.context) + val view = inflater.inflate(R.layout.phone_number_list_item, parent, false) + return QkViewHolder(view).apply { + view.setOnClickListener { + val phoneNumber = getItem(adapterPosition) + selectedItem = phoneNumber.id + } + } + } + + override fun onBindViewHolder(holder: QkViewHolder, position: Int) { + val phoneNumber = getItem(position) + val view = holder.itemView + + view.number.radioButton.isChecked = phoneNumber.id == selectedItem + view.number.titleView.text = phoneNumber.address + view.number.summaryView.text = when (phoneNumber.isDefault) { + true -> context.getString(R.string.compose_number_picker_default, phoneNumber.type) + false -> phoneNumber.type + } + } + + override fun onDatasetChanged() { + super.onDatasetChanged() + selectedItem = data.find { number -> number.isDefault }?.id ?: data.firstOrNull()?.id + } + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/FileBinder.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/FileBinder.kt index 73d9ab8362909423e4718839fc24d29a482a1916..ff12a1bdd32ca1bb562ae220f48211aff5a3c5c5 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/FileBinder.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/FileBinder.kt @@ -21,9 +21,9 @@ package com.moez.QKSMS.feature.compose.part import android.annotation.SuppressLint import android.content.Context import android.view.Gravity -import android.view.View import android.widget.FrameLayout import com.moez.QKSMS.R +import com.moez.QKSMS.common.base.QkViewHolder import com.moez.QKSMS.common.util.Colors import com.moez.QKSMS.common.util.extensions.resolveThemeColor import com.moez.QKSMS.common.util.extensions.setBackgroundTint @@ -34,7 +34,7 @@ import com.moez.QKSMS.model.MmsPart import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers -import kotlinx.android.synthetic.main.mms_file_list_item.view.* +import kotlinx.android.synthetic.main.mms_file_list_item.* import javax.inject.Inject class FileBinder @Inject constructor(colors: Colors, private val context: Context) : PartBinder() { @@ -47,16 +47,16 @@ class FileBinder @Inject constructor(colors: Colors, private val context: Contex @SuppressLint("CheckResult") override fun bindPart( - view: View, + holder: QkViewHolder, part: MmsPart, message: Message, canGroupWithPrevious: Boolean, canGroupWithNext: Boolean ) { BubbleUtils.getBubble(false, canGroupWithPrevious, canGroupWithNext, message.isMe()) - .let(view.fileBackground::setBackgroundResource) + .let(holder.fileBackground::setBackgroundResource) - view.setOnClickListener { clicks.onNext(part.id) } + holder.containerView.setOnClickListener { clicks.onNext(part.id) } Observable.just(part.getUri()) .map(context.contentResolver::openInputStream) @@ -71,23 +71,23 @@ class FileBinder @Inject constructor(colors: Colors, private val context: Contex } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe { size -> view.size.text = size } + .subscribe { size -> holder.size.text = size } - view.filename.text = part.name + holder.filename.text = part.name - val params = view.fileBackground.layoutParams as FrameLayout.LayoutParams + val params = holder.fileBackground.layoutParams as FrameLayout.LayoutParams if (!message.isMe()) { - view.fileBackground.layoutParams = params.apply { gravity = Gravity.START } - view.fileBackground.setBackgroundTint(theme.theme) - view.icon.setTint(theme.textPrimary) - view.filename.setTextColor(theme.textPrimary) - view.size.setTextColor(theme.textTertiary) + holder.fileBackground.layoutParams = params.apply { gravity = Gravity.START } + holder.fileBackground.setBackgroundTint(theme.theme) + holder.icon.setTint(theme.textPrimary) + holder.filename.setTextColor(theme.textPrimary) + holder.size.setTextColor(theme.textTertiary) } else { - view.fileBackground.layoutParams = params.apply { gravity = Gravity.END } - view.fileBackground.setBackgroundTint(view.context.resolveThemeColor(R.attr.bubbleColor)) - view.icon.setTint(view.context.resolveThemeColor(android.R.attr.textColorSecondary)) - view.filename.setTextColor(view.context.resolveThemeColor(android.R.attr.textColorPrimary)) - view.size.setTextColor(view.context.resolveThemeColor(android.R.attr.textColorTertiary)) + holder.fileBackground.layoutParams = params.apply { gravity = Gravity.END } + holder.fileBackground.setBackgroundTint(holder.containerView.context.resolveThemeColor(R.attr.bubbleColor)) + holder.icon.setTint(holder.containerView.context.resolveThemeColor(android.R.attr.textColorSecondary)) + holder.filename.setTextColor(holder.containerView.context.resolveThemeColor(android.R.attr.textColorPrimary)) + holder.size.setTextColor(holder.containerView.context.resolveThemeColor(android.R.attr.textColorTertiary)) } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/MediaBinder.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/MediaBinder.kt index dafece1c6225f4d8e2c33694a54e80d4fdf5bc68..1ac437a5c2f96d3ab0423c3ce869dc37a2ff975e 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/MediaBinder.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/MediaBinder.kt @@ -19,8 +19,8 @@ package com.moez.QKSMS.feature.compose.part import android.content.Context -import android.view.View import com.moez.QKSMS.R +import com.moez.QKSMS.common.base.QkViewHolder import com.moez.QKSMS.common.util.Colors import com.moez.QKSMS.common.util.extensions.setVisible import com.moez.QKSMS.common.widget.BubbleImageView @@ -29,7 +29,7 @@ import com.moez.QKSMS.extensions.isVideo import com.moez.QKSMS.model.Message import com.moez.QKSMS.model.MmsPart import com.moez.QKSMS.util.GlideApp -import kotlinx.android.synthetic.main.mms_preview_list_item.view.* +import kotlinx.android.synthetic.main.mms_preview_list_item.* import javax.inject.Inject class MediaBinder @Inject constructor(colors: Colors, private val context: Context) : PartBinder() { @@ -40,23 +40,23 @@ class MediaBinder @Inject constructor(colors: Colors, private val context: Conte override fun canBindPart(part: MmsPart) = part.isImage() || part.isVideo() override fun bindPart( - view: View, + holder: QkViewHolder, part: MmsPart, message: Message, canGroupWithPrevious: Boolean, canGroupWithNext: Boolean ) { - view.video.setVisible(part.isVideo()) - view.setOnClickListener { clicks.onNext(part.id) } + holder.video.setVisible(part.isVideo()) + holder.containerView.setOnClickListener { clicks.onNext(part.id) } - view.thumbnail.bubbleStyle = when { + holder.thumbnail.bubbleStyle = when { !canGroupWithPrevious && canGroupWithNext -> if (message.isMe()) BubbleImageView.Style.OUT_FIRST else BubbleImageView.Style.IN_FIRST canGroupWithPrevious && canGroupWithNext -> if (message.isMe()) BubbleImageView.Style.OUT_MIDDLE else BubbleImageView.Style.IN_MIDDLE canGroupWithPrevious && !canGroupWithNext -> if (message.isMe()) BubbleImageView.Style.OUT_LAST else BubbleImageView.Style.IN_LAST else -> BubbleImageView.Style.ONLY } - GlideApp.with(context).load(part.getUri()).fitCenter().into(view.thumbnail) + GlideApp.with(context).load(part.getUri()).fitCenter().into(holder.thumbnail) } } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartBinder.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartBinder.kt index fc3fb7fc4733fbefd73f9b6b19cacb5e05d6fd43..d56f8547d2c625ea9bad08515f64a66f61fb71f6 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartBinder.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartBinder.kt @@ -18,7 +18,7 @@ */ package com.moez.QKSMS.feature.compose.part -import android.view.View +import com.moez.QKSMS.common.base.QkViewHolder import com.moez.QKSMS.common.util.Colors import com.moez.QKSMS.model.Message import com.moez.QKSMS.model.MmsPart @@ -36,7 +36,7 @@ abstract class PartBinder { abstract fun canBindPart(part: MmsPart): Boolean abstract fun bindPart( - view: View, + holder: QkViewHolder, part: MmsPart, message: Message, canGroupWithPrevious: Boolean, diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartsAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartsAdapter.kt index 31c67aee5bdd8e760c84a0b26a24d442ab84f1b3..567f49268b9bf7520bacef9c3a5a3c71674d6541 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartsAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartsAdapter.kt @@ -31,7 +31,7 @@ import com.moez.QKSMS.feature.compose.BubbleUtils.canGroup import com.moez.QKSMS.model.Message import com.moez.QKSMS.model.MmsPart import io.reactivex.Observable -import kotlinx.android.synthetic.main.message_list_item_in.view.* +import kotlinx.android.synthetic.main.message_list_item_in.* import javax.inject.Inject class PartsAdapter @Inject constructor( @@ -54,35 +54,34 @@ class PartsAdapter @Inject constructor( private lateinit var message: Message private var previous: Message? = null private var next: Message? = null - private var messageView: View? = null + private var holder: QkViewHolder? = null private var bodyVisible: Boolean = true - fun setData(message: Message, previous: Message?, next: Message?, messageView: View) { + fun setData(message: Message, previous: Message?, next: Message?, holder: QkViewHolder) { this.message = message this.previous = previous this.next = next - this.messageView = messageView - this.bodyVisible = messageView.body.visibility == View.VISIBLE + this.holder = holder + this.bodyVisible = holder.body.visibility == View.VISIBLE this.data = message.parts.filter { !it.isSmil() && !it.isText() } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { val layout = partBinders.getOrNull(viewType)?.partLayout ?: 0 val view = LayoutInflater.from(parent.context).inflate(layout, parent, false) - messageView?.let(view::forwardTouches) + holder?.containerView?.let(view::forwardTouches) return QkViewHolder(view) } override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val part = data[position] - val view = holder.containerView val canGroupWithPrevious = canGroup(message, previous) || position > 0 val canGroupWithNext = canGroup(message, next) || position < itemCount - 1 || bodyVisible partBinders .firstOrNull { it.canBindPart(part) } - ?.bindPart(view, part, message, canGroupWithPrevious, canGroupWithNext) + ?.bindPart(holder, part, message, canGroupWithPrevious, canGroupWithNext) } override fun getItemViewType(position: Int): Int { diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/VCardBinder.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/VCardBinder.kt index 294f23c79dd16d2447ac4546810a0c34e84a4e57..eebcdaf61c98680d1822645575295711ec9a4c5f 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/VCardBinder.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/VCardBinder.kt @@ -21,9 +21,9 @@ package com.moez.QKSMS.feature.compose.part import android.content.Context import android.util.Log import android.view.Gravity -import android.view.View import android.widget.FrameLayout import com.moez.QKSMS.R +import com.moez.QKSMS.common.base.QkViewHolder import com.moez.QKSMS.common.util.Colors import com.moez.QKSMS.common.util.extensions.resolveThemeColor import com.moez.QKSMS.common.util.extensions.setBackgroundTint @@ -37,7 +37,7 @@ import ezvcard.Ezvcard import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers -import kotlinx.android.synthetic.main.mms_vcard_list_item.view.* +import kotlinx.android.synthetic.main.mms_vcard_list_item.* import javax.inject.Inject class VCardBinder @Inject constructor(colors: Colors, private val context: Context) : PartBinder() { @@ -48,37 +48,37 @@ class VCardBinder @Inject constructor(colors: Colors, private val context: Conte override fun canBindPart(part: MmsPart) = part.isVCard() override fun bindPart( - view: View, + holder: QkViewHolder, part: MmsPart, message: Message, canGroupWithPrevious: Boolean, canGroupWithNext: Boolean ) { BubbleUtils.getBubble(false, canGroupWithPrevious, canGroupWithNext, message.isMe()) - .let(view.vCardBackground::setBackgroundResource) + .let(holder.vCardBackground::setBackgroundResource) - view.setOnClickListener { clicks.onNext(part.id) } + holder.containerView.setOnClickListener { clicks.onNext(part.id) } Observable.just(part.getUri()) .map(context.contentResolver::openInputStream) .mapNotNull { inputStream -> inputStream.use { Ezvcard.parse(it).first() } } .subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe( { vcard -> view.name?.text = vcard.formattedName.value }, { throwable -> Log.i("VCardBinder.kt", "Name field is null") } ) + .subscribe( { vcard -> holder.name?.text = vcard.formattedName.value }, { throwable -> Log.i("VCardBinder.kt", "Name field is null") } ) - val params = view.vCardBackground.layoutParams as FrameLayout.LayoutParams + val params = holder.vCardBackground.layoutParams as FrameLayout.LayoutParams if (!message.isMe()) { - view.vCardBackground.layoutParams = params.apply { gravity = Gravity.START } - view.vCardBackground.setBackgroundTint(theme.theme) - view.vCardAvatar.setTint(theme.textPrimary) - view.name.setTextColor(theme.textPrimary) - view.label.setTextColor(theme.textTertiary) + holder.vCardBackground.layoutParams = params.apply { gravity = Gravity.START } + holder.vCardBackground.setBackgroundTint(theme.theme) + holder.vCardAvatar.setTint(theme.textPrimary) + holder.name.setTextColor(theme.textPrimary) + holder.label.setTextColor(theme.textTertiary) } else { - view.vCardBackground.layoutParams = params.apply { gravity = Gravity.END } - view.vCardBackground.setBackgroundTint(view.context.resolveThemeColor(R.attr.bubbleColor)) - view.vCardAvatar.setTint(view.context.resolveThemeColor(android.R.attr.textColorSecondary)) - view.name.setTextColor(view.context.resolveThemeColor(android.R.attr.textColorPrimary)) - view.label.setTextColor(view.context.resolveThemeColor(android.R.attr.textColorTertiary)) + holder.vCardBackground.layoutParams = params.apply { gravity = Gravity.END } + holder.vCardBackground.setBackgroundTint(holder.containerView.context.resolveThemeColor(R.attr.bubbleColor)) + holder.vCardAvatar.setTint(holder.containerView.context.resolveThemeColor(android.R.attr.textColorSecondary)) + holder.name.setTextColor(holder.containerView.context.resolveThemeColor(android.R.attr.textColorPrimary)) + holder.label.setTextColor(holder.containerView.context.resolveThemeColor(android.R.attr.textColorTertiary)) } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..f5ca9f71d41880a2451717d1bfcc8354485b491a --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsActivity.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.contacts + +import android.app.Activity +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.core.view.isVisible +import androidx.lifecycle.ViewModelProviders +import com.jakewharton.rxbinding2.view.clicks +import com.jakewharton.rxbinding2.widget.editorActions +import com.jakewharton.rxbinding2.widget.textChanges +import com.moez.QKSMS.R +import com.moez.QKSMS.common.ViewModelFactory +import com.moez.QKSMS.common.base.QkThemedActivity +import com.moez.QKSMS.common.util.extensions.hideKeyboard +import com.moez.QKSMS.common.util.extensions.resolveThemeColor +import com.moez.QKSMS.common.util.extensions.setBackgroundTint +import com.moez.QKSMS.common.util.extensions.showKeyboard +import com.moez.QKSMS.common.widget.QkDialog +import com.moez.QKSMS.extensions.Optional +import com.moez.QKSMS.feature.compose.editing.ComposeItem +import com.moez.QKSMS.feature.compose.editing.ComposeItemAdapter +import com.moez.QKSMS.feature.compose.editing.PhoneNumberAction +import com.moez.QKSMS.feature.compose.editing.PhoneNumberPickerAdapter +import dagger.android.AndroidInjection +import io.reactivex.Observable +import io.reactivex.subjects.PublishSubject +import io.reactivex.subjects.Subject +import kotlinx.android.synthetic.main.contacts_activity.* +import javax.inject.Inject + +class ContactsActivity : QkThemedActivity(), ContactsContract { + + companion object { + const val SharingKey = "sharing" + const val ChipsKey = "chips" + } + + @Inject lateinit var contactsAdapter: ComposeItemAdapter + @Inject lateinit var phoneNumberAdapter: PhoneNumberPickerAdapter + @Inject lateinit var viewModelFactory: ViewModelFactory + + override val queryChangedIntent: Observable by lazy { search.textChanges() } + override val queryClearedIntent: Observable<*> by lazy { cancel.clicks() } + override val queryEditorActionIntent: Observable by lazy { search.editorActions() } + override val composeItemPressedIntent: Subject by lazy { contactsAdapter.clicks } + override val composeItemLongPressedIntent: Subject by lazy { contactsAdapter.longClicks } + override val phoneNumberSelectedIntent: Subject> by lazy { phoneNumberAdapter.selectedItemChanges } + override val phoneNumberActionIntent: Subject = PublishSubject.create() + + private val viewModel by lazy { ViewModelProviders.of(this, viewModelFactory)[ContactsViewModel::class.java] } + + private val phoneNumberDialog by lazy { + QkDialog(this).apply { + titleRes = R.string.compose_number_picker_title + adapter = phoneNumberAdapter + positiveButton = R.string.compose_number_picker_always + positiveButtonListener = { phoneNumberActionIntent.onNext(PhoneNumberAction.ALWAYS) } + negativeButton = R.string.compose_number_picker_once + negativeButtonListener = { phoneNumberActionIntent.onNext(PhoneNumberAction.JUST_ONCE) } + cancelListener = { phoneNumberActionIntent.onNext(PhoneNumberAction.CANCEL) } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + AndroidInjection.inject(this) + super.onCreate(savedInstanceState) + setContentView(R.layout.contacts_activity) + showBackButton(true) + viewModel.bindView(this) + + contacts.adapter = contactsAdapter + + // These theme attributes don't apply themselves on API 21 + if (Build.VERSION.SDK_INT <= 22) { + search.setBackgroundTint(resolveThemeColor(R.attr.bubbleColor)) + } + } + + override fun render(state: ContactsState) { + cancel.isVisible = state.query.length > 1 + + contactsAdapter.data = state.composeItems + + if (state.selectedContact != null && !phoneNumberDialog.isShowing) { + phoneNumberAdapter.data = state.selectedContact.numbers + phoneNumberDialog.subtitle = state.selectedContact.name + phoneNumberDialog.show() + } else if (state.selectedContact == null && phoneNumberDialog.isShowing) { + phoneNumberDialog.dismiss() + } + } + + override fun clearQuery() { + search.text = null + } + + override fun openKeyboard() { + search.postDelayed({ + search.showKeyboard() + }, 200) + } + + override fun finish(result: HashMap) { + search.hideKeyboard() + val intent = Intent().putExtra(ChipsKey, result) + setResult(Activity.RESULT_OK, intent) + finish() + } + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsActivityModule.kt b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsActivityModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..a2d21b331a102cad738d0b0706dcc9bd48bccaeb --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsActivityModule.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.contacts + +import androidx.lifecycle.ViewModel +import com.moez.QKSMS.injection.ViewModelKey +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoMap + +@Module +class ContactsActivityModule { + + @Provides + fun provideIsSharing(activity: ContactsActivity): Boolean { + return activity.intent.extras?.getBoolean(ContactsActivity.SharingKey, false) ?: false + } + + @Provides + fun provideChips(activity: ContactsActivity): HashMap { + return activity.intent.extras?.getSerializable(ContactsActivity.ChipsKey) + ?.let { serializable -> serializable as? HashMap } + ?: hashMapOf() + } + + @Provides + @IntoMap + @ViewModelKey(ContactsViewModel::class) + fun provideContactsViewModel(viewModel: ContactsViewModel): ViewModel = viewModel + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsContract.kt b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsContract.kt new file mode 100644 index 0000000000000000000000000000000000000000..cf6c55095261c08429f660fe4d74b4ce5415bd26 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsContract.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.contacts + +import com.moez.QKSMS.common.base.QkView +import com.moez.QKSMS.extensions.Optional +import com.moez.QKSMS.feature.compose.editing.ComposeItem +import com.moez.QKSMS.feature.compose.editing.PhoneNumberAction +import io.reactivex.Observable +import io.reactivex.subjects.Subject + +interface ContactsContract : QkView { + + val queryChangedIntent: Observable + val queryClearedIntent: Observable<*> + val queryEditorActionIntent: Observable + val composeItemPressedIntent: Subject + val composeItemLongPressedIntent: Subject + val phoneNumberSelectedIntent: Subject> + val phoneNumberActionIntent: Subject + + fun clearQuery() + fun openKeyboard() + fun finish(result: HashMap) + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsState.kt new file mode 100644 index 0000000000000000000000000000000000000000..9829d905b8533c3d0855d9fa3c3e4c0c4de5341c --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsState.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.contacts + +import com.moez.QKSMS.feature.compose.editing.ComposeItem +import com.moez.QKSMS.model.Contact + +data class ContactsState( + val query: String = "", + val composeItems: List = ArrayList(), + val selectedContact: Contact? = null // For phone number picker +) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..c101d218e3b7597d82538ca9ac29dbff600cce4c --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsViewModel.kt @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2019 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 com.moez.QKSMS.feature.contacts + +import android.view.inputmethod.EditorInfo +import com.moez.QKSMS.common.base.QkViewModel +import com.moez.QKSMS.extensions.mapNotNull +import com.moez.QKSMS.extensions.removeAccents +import com.moez.QKSMS.feature.compose.editing.ComposeItem +import com.moez.QKSMS.feature.compose.editing.PhoneNumberAction +import com.moez.QKSMS.filter.ContactFilter +import com.moez.QKSMS.filter.ContactGroupFilter +import com.moez.QKSMS.interactor.SetDefaultPhoneNumber +import com.moez.QKSMS.model.Contact +import com.moez.QKSMS.model.ContactGroup +import com.moez.QKSMS.model.Conversation +import com.moez.QKSMS.model.PhoneNumber +import com.moez.QKSMS.model.Recipient +import com.moez.QKSMS.repository.ContactRepository +import com.moez.QKSMS.repository.ConversationRepository +import com.moez.QKSMS.util.PhoneNumberUtils +import com.uber.autodispose.android.lifecycle.scope +import com.uber.autodispose.autoDisposable +import io.reactivex.Observable +import io.reactivex.rxkotlin.Observables +import io.reactivex.rxkotlin.withLatestFrom +import io.reactivex.schedulers.Schedulers +import io.realm.RealmList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.rx2.awaitFirst +import javax.inject.Inject + +class ContactsViewModel @Inject constructor( + sharing: Boolean, + serializedChips: HashMap, + private val contactFilter: ContactFilter, + private val contactGroupFilter: ContactGroupFilter, + private val contactsRepo: ContactRepository, + private val conversationRepo: ConversationRepository, + private val phoneNumberUtils: PhoneNumberUtils, + private val setDefaultPhoneNumber: SetDefaultPhoneNumber +) : QkViewModel(ContactsState()) { + + private val contactGroups: Observable> by lazy { contactsRepo.getUnmanagedContactGroups() } + private val contacts: Observable> by lazy { contactsRepo.getUnmanagedContacts() } + private val recents: Observable> by lazy { + if (sharing) conversationRepo.getUnmanagedConversations() else Observable.just(listOf()) + } + private val starredContacts: Observable> by lazy { contactsRepo.getUnmanagedContacts(true) } + + private val selectedChips = Observable.just(serializedChips) + .observeOn(Schedulers.io()) + .map { hashmap -> + hashmap.map { (address, lookupKey) -> + Recipient(address = address, contact = lookupKey?.let(contactsRepo::getUnmanagedContact)) + } + } + + private var shouldOpenKeyboard: Boolean = true + + override fun bindView(view: ContactsContract) { + super.bindView(view) + + if (shouldOpenKeyboard) { + view.openKeyboard() + shouldOpenKeyboard = false + } + + // Update the state's query, so we know if we should show the cancel button + view.queryChangedIntent + .autoDisposable(view.scope()) + .subscribe { query -> newState { copy(query = query.toString()) } } + + // Clear the query + view.queryClearedIntent + .autoDisposable(view.scope()) + .subscribe { view.clearQuery() } + + // 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, recents, starredContacts, contactGroups, contacts, selectedChips + ) { query, recents, starredContacts, contactGroups, contacts, selectedChips -> + val composeItems = mutableListOf() + if (query.isBlank()) { + composeItems += recents + .filter { conversation -> + conversation.recipients.any { recipient -> + selectedChips.none { chip -> + if (recipient.contact == null) { + chip.address == recipient.address + } else { + chip.contact?.lookupKey == recipient.contact?.lookupKey + } + } + } + } + .map(ComposeItem::Recent) + + composeItems += starredContacts + .filter { contact -> selectedChips.none { it.contact?.lookupKey == contact.lookupKey } } + .map(ComposeItem::Starred) + + composeItems += contactGroups + .filter { group -> + group.contacts.any { contact -> + selectedChips.none { chip -> chip.contact?.lookupKey == contact.lookupKey } + } + } + .map(ComposeItem::Group) + + composeItems += contacts + .filter { contact -> selectedChips.none { it.contact?.lookupKey == contact.lookupKey } } + .map(ComposeItem::Person) + } else { + // If the entry is a valid destination, allow it as a recipient + if (phoneNumberUtils.isPossibleNumber(query.toString())) { + val newAddress = phoneNumberUtils.formatNumber(query) + val newContact = Contact(numbers = RealmList(PhoneNumber(address = newAddress))) + composeItems += ComposeItem.New(newContact) + } + + // 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() + composeItems += starredContacts + .asSequence() + .filter { contact -> selectedChips.none { it.contact?.lookupKey == contact.lookupKey } } + .filter { contact -> contactFilter.filter(contact, normalizedQuery) } + .map(ComposeItem::Starred) + + composeItems += contactGroups + .asSequence() + .filter { group -> + group.contacts.any { contact -> + selectedChips.none { chip -> chip.contact?.lookupKey == contact.lookupKey } + } + } + .filter { group -> contactGroupFilter.filter(group, normalizedQuery) } + .map(ComposeItem::Group) + + composeItems += contacts + .asSequence() + .filter { contact -> selectedChips.none { it.contact?.lookupKey == contact.lookupKey } } + .filter { contact -> contactFilter.filter(contact, normalizedQuery) } + .map(ComposeItem::Person) + } + + composeItems + } + .subscribeOn(Schedulers.computation()) + .autoDisposable(view.scope()) + .subscribe { items -> newState { copy(composeItems = items) } } + + // Listen for ComposeItems being selected, and then send them off to the number picker dialog in case + // the user needs to select a phone number + view.queryEditorActionIntent + .filter { actionId -> actionId == EditorInfo.IME_ACTION_DONE } + .withLatestFrom(state) { _, state -> state } + .mapNotNull { state -> state.composeItems.firstOrNull() } + .mergeWith(view.composeItemPressedIntent) + .map { composeItem -> composeItem to false } + .mergeWith(view.composeItemLongPressedIntent.map { composeItem -> composeItem to true }) + .observeOn(Schedulers.io()) + .autoDisposable(view.scope()) + .subscribe { (composeItem, force) -> + view.finish(HashMap(composeItem.getContacts().associate { contact -> + if (contact.numbers.size == 1 || contact.getDefaultNumber() != null && !force) { + val address = contact.getDefaultNumber()?.address ?: contact.numbers[0]!!.address + address to contact.lookupKey + } else { + runBlocking { + newState { copy(selectedContact = contact) } + val action = view.phoneNumberActionIntent.awaitFirst() + newState { copy(selectedContact = null) } + val numberId = view.phoneNumberSelectedIntent.awaitFirst().value + val number = contact.numbers.find { number -> number.id == numberId } + + if (action == PhoneNumberAction.CANCEL || number == null) { + return@runBlocking null + } + + if (action == PhoneNumberAction.ALWAYS) { + val params = SetDefaultPhoneNumber.Params(contact.lookupKey, number.id) + setDefaultPhoneNumber.execute(params) + } + + number.address to contact.lookupKey + } ?: return@subscribe + } + })) + } + } + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoAdapter.kt new file mode 100644 index 0000000000000000000000000000000000000000..f75034171b76db147257f0382bc174d2e68cb3ee --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoAdapter.kt @@ -0,0 +1,153 @@ +package com.moez.QKSMS.feature.conversationinfo + +import android.content.Context +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import com.jakewharton.rxbinding2.view.clicks +import com.moez.QKSMS.R +import com.moez.QKSMS.common.base.QkAdapter +import com.moez.QKSMS.common.base.QkViewHolder +import com.moez.QKSMS.common.util.Colors +import com.moez.QKSMS.common.util.extensions.setTint +import com.moez.QKSMS.common.util.extensions.setVisible +import com.moez.QKSMS.extensions.isVideo +import com.moez.QKSMS.feature.conversationinfo.ConversationInfoItem.* +import com.moez.QKSMS.util.GlideApp +import io.reactivex.subjects.PublishSubject +import io.reactivex.subjects.Subject +import kotlinx.android.synthetic.main.conversation_info_settings.* +import kotlinx.android.synthetic.main.conversation_media_list_item.* +import kotlinx.android.synthetic.main.conversation_recipient_list_item.* +import javax.inject.Inject + +class ConversationInfoAdapter @Inject constructor( + private val context: Context, + private val colors: Colors +) : QkAdapter() { + + val recipientClicks: Subject = PublishSubject.create() + val recipientLongClicks: Subject = PublishSubject.create() + val themeClicks: Subject = PublishSubject.create() + val nameClicks: Subject = PublishSubject.create() + val notificationClicks: Subject = PublishSubject.create() + val archiveClicks: Subject = PublishSubject.create() + val blockClicks: Subject = PublishSubject.create() + val deleteClicks: Subject = PublishSubject.create() + val mediaClicks: Subject = PublishSubject.create() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + 0 -> QkViewHolder(inflater.inflate(R.layout.conversation_recipient_list_item, parent, false)).apply { + itemView.setOnClickListener { + val item = getItem(adapterPosition) as? ConversationInfoRecipient + item?.value?.id?.run(recipientClicks::onNext) + } + + itemView.setOnLongClickListener { + val item = getItem(adapterPosition) as? ConversationInfoRecipient + item?.value?.id?.run(recipientLongClicks::onNext) + true + } + + theme.setOnClickListener { + val item = getItem(adapterPosition) as? ConversationInfoRecipient + item?.value?.id?.run(themeClicks::onNext) + } + } + + 1 -> QkViewHolder(inflater.inflate(R.layout.conversation_info_settings, parent, false)).apply { + groupName.clicks().subscribe(nameClicks) + notifications.clicks().subscribe(notificationClicks) + archive.clicks().subscribe(archiveClicks) + block.clicks().subscribe(blockClicks) + delete.clicks().subscribe(deleteClicks) + } + + 2 -> QkViewHolder(inflater.inflate(R.layout.conversation_media_list_item, parent, false)).apply { + itemView.setOnClickListener { + val item = getItem(adapterPosition) as? ConversationInfoMedia + item?.value?.id?.run(mediaClicks::onNext) + } + } + + else -> throw IllegalStateException() + } + } + + override fun onBindViewHolder(holder: QkViewHolder, position: Int) { + when (val item = getItem(position)) { + is ConversationInfoRecipient -> { + val recipient = item.value + holder.avatar.setRecipient(recipient) + + holder.name.text = recipient.contact?.name ?: recipient.address + + holder.address.text = recipient.address + holder.address.setVisible(recipient.contact != null) + + holder.add.setVisible(recipient.contact == null) + + val theme = colors.theme(recipient) + holder.theme.setTint(theme.theme) + } + + is ConversationInfoSettings -> { + holder.groupName.isVisible = item.recipients.size > 1 + holder.groupName.summary = item.name + + holder.notifications.isEnabled = !item.blocked + + holder.archive.isEnabled = !item.blocked + holder.archive.title = context.getString(when (item.archived) { + true -> R.string.info_unarchive + false -> R.string.info_archive + }) + + holder.block.title = context.getString(when (item.blocked) { + true -> R.string.info_unblock + false -> R.string.info_block + }) + } + + is ConversationInfoMedia -> { + val part = item.value + + GlideApp.with(context) + .load(part.getUri()) + .fitCenter() + .into(holder.thumbnail) + + holder.video.isVisible = part.isVideo() + } + } + } + + override fun getItemViewType(position: Int): Int { + return when (data[position]) { + is ConversationInfoRecipient -> 0 + is ConversationInfoSettings -> 1 + is ConversationInfoMedia -> 2 + } + } + + override fun areItemsTheSame(old: ConversationInfoItem, new: ConversationInfoItem): Boolean { + return when { + old is ConversationInfoRecipient && new is ConversationInfoRecipient -> { + old.value.id == new.value.id + } + + old is ConversationInfoSettings && new is ConversationInfoSettings -> { + true + } + + old is ConversationInfoMedia && new is ConversationInfoMedia -> { + old.value.id == new.value.id + } + + else -> false + } + } + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoController.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoController.kt index 45a010e315a605070890dfaeb48022d4c58949c3..95a1f241b4e86dfd9ce6ba73c18265495d1f679d 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoController.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoController.kt @@ -20,15 +20,13 @@ package com.moez.QKSMS.feature.conversationinfo import android.view.View import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.GridLayoutManager import com.bluelinelabs.conductor.RouterTransaction -import com.jakewharton.rxbinding2.view.clicks import com.moez.QKSMS.R import com.moez.QKSMS.common.Navigator import com.moez.QKSMS.common.QkChangeHandler import com.moez.QKSMS.common.base.QkController -import com.moez.QKSMS.common.util.extensions.animateLayoutChanges import com.moez.QKSMS.common.util.extensions.scrapViews -import com.moez.QKSMS.common.util.extensions.setVisible import com.moez.QKSMS.common.widget.FieldDialog import com.moez.QKSMS.feature.blocking.BlockingDialog import com.moez.QKSMS.feature.conversationinfo.injection.ConversationInfoModule @@ -49,9 +47,7 @@ class ConversationInfoController( @Inject override lateinit var presenter: ConversationInfoPresenter @Inject lateinit var blockingDialog: BlockingDialog @Inject lateinit var navigator: Navigator - @Inject lateinit var recipientAdapter: ConversationRecipientAdapter - @Inject lateinit var mediaAdapter: ConversationMediaAdapter - @Inject lateinit var itemDecoration: GridSpacingItemDecoration + @Inject lateinit var adapter: ConversationInfoAdapter private val nameDialog: FieldDialog by lazy { FieldDialog(activity!!, activity!!.getString(R.string.info_name), nameChangeSubject::onNext) @@ -71,17 +67,17 @@ class ConversationInfoController( } override fun onViewCreated() { - items.postDelayed({ items?.animateLayoutChanges = true }, 100) - - recipients.adapter = recipientAdapter - - media.adapter = mediaAdapter - media.addItemDecoration(itemDecoration) + recyclerView.adapter = adapter + recyclerView.addItemDecoration(GridSpacingItemDecoration(adapter, activity!!)) + recyclerView.layoutManager = GridLayoutManager(activity, 3).apply { + spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int = if (adapter.getItemViewType(position) == 2) 1 else 3 + } + } - themedActivity - ?.theme + themedActivity?.theme ?.autoDisposable(scope()) - ?.subscribe { recipients?.scrapViews() } + ?.subscribe { recyclerView.scrapViews() } } override fun onAttach(view: View) { @@ -91,59 +87,31 @@ class ConversationInfoController( showBackButton(true) } - override fun recipientClicks(): Observable = recipientAdapter.clicks - - override fun nameClicks(): Observable<*> = name.clicks() - - override fun nameChanges(): Observable = nameChangeSubject - - override fun notificationClicks(): Observable<*> = notifications.clicks() - - override fun themeClicks(): Observable<*> = themePrefs.clicks() - - override fun archiveClicks(): Observable<*> = archive.clicks() - - override fun blockClicks(): Observable<*> = block.clicks() - - override fun deleteClicks(): Observable<*> = delete.clicks() - - override fun confirmDelete(): Observable<*> = confirmDeleteSubject - override fun render(state: ConversationInfoState) { if (state.hasError) { activity?.finish() return } - themedActivity?.threadId?.onNext(state.threadId) - recipientAdapter.threadId = state.threadId - recipientAdapter.updateData(state.recipients) - - name.setVisible(state.recipients?.size ?: 0 >= 2) - name.summary = state.name - - notifications.isEnabled = !state.blocked - - themePrefs.isEnabled = !state.blocked - - archive.isEnabled = !state.blocked - archive.title = activity?.getString(when (state.archived) { - true -> R.string.info_unarchive - false -> R.string.info_archive - }) - - block.title = activity?.getString(when (state.blocked) { - true -> R.string.info_unblock - false -> R.string.info_block - }) - - mediaAdapter.updateData(state.media) + adapter.data = state.data } + override fun recipientClicks(): Observable = adapter.recipientClicks + override fun recipientLongClicks(): Observable = adapter.recipientLongClicks + override fun themeClicks(): Observable = adapter.themeClicks + override fun nameClicks(): Observable<*> = adapter.nameClicks + override fun nameChanges(): Observable = nameChangeSubject + override fun notificationClicks(): Observable<*> = adapter.notificationClicks + override fun archiveClicks(): Observable<*> = adapter.archiveClicks + override fun blockClicks(): Observable<*> = adapter.blockClicks + override fun deleteClicks(): Observable<*> = adapter.deleteClicks + override fun confirmDelete(): Observable<*> = confirmDeleteSubject + override fun mediaClicks(): Observable = adapter.mediaClicks + override fun showNameDialog(name: String) = nameDialog.setText(name).show() - override fun showThemePicker(threadId: Long) { - router.pushController(RouterTransaction.with(ThemePickerController(threadId)) + override fun showThemePicker(recipientId: Long) { + router.pushController(RouterTransaction.with(ThemePickerController(recipientId)) .pushChangeHandler(QkChangeHandler()) .popChangeHandler(QkChangeHandler())) } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoItem.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoItem.kt new file mode 100644 index 0000000000000000000000000000000000000000..810aa21c8e66c7deafbb957efb643df71a59d737 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoItem.kt @@ -0,0 +1,20 @@ +package com.moez.QKSMS.feature.conversationinfo + +import com.moez.QKSMS.model.MmsPart +import com.moez.QKSMS.model.Recipient +import io.realm.RealmList + +sealed class ConversationInfoItem { + + data class ConversationInfoRecipient(val value: Recipient) : ConversationInfoItem() + + data class ConversationInfoSettings( + val name: String, + val recipients: RealmList, + val archived: Boolean, + val blocked: Boolean + ) : ConversationInfoItem() + + data class ConversationInfoMedia(val value: MmsPart) : ConversationInfoItem() + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoPresenter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoPresenter.kt index 4f6029021e530e5450a604dd3e45625528c03803..07860ca2c957442dcd111d3fbde73ca8bbd15ee2 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoPresenter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoPresenter.kt @@ -18,22 +18,28 @@ */ package com.moez.QKSMS.feature.conversationinfo +import android.content.Context import androidx.lifecycle.Lifecycle +import com.moez.QKSMS.R import com.moez.QKSMS.common.Navigator import com.moez.QKSMS.common.base.QkPresenter +import com.moez.QKSMS.common.util.ClipboardUtils +import com.moez.QKSMS.common.util.extensions.makeToast import com.moez.QKSMS.extensions.asObservable import com.moez.QKSMS.extensions.mapNotNull +import com.moez.QKSMS.feature.conversationinfo.ConversationInfoItem.ConversationInfoMedia +import com.moez.QKSMS.feature.conversationinfo.ConversationInfoItem.ConversationInfoRecipient import com.moez.QKSMS.interactor.DeleteConversations import com.moez.QKSMS.interactor.MarkArchived import com.moez.QKSMS.interactor.MarkUnarchived -import com.moez.QKSMS.listener.ContactAddedListener import com.moez.QKSMS.manager.PermissionManager import com.moez.QKSMS.model.Conversation import com.moez.QKSMS.repository.ConversationRepository import com.moez.QKSMS.repository.MessageRepository import com.uber.autodispose.android.lifecycle.scope import com.uber.autodispose.autoDisposable -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.subjects.BehaviorSubject @@ -44,7 +50,7 @@ import javax.inject.Named class ConversationInfoPresenter @Inject constructor( @Named("threadId") threadId: Long, messageRepo: MessageRepository, - private val contactAddedListener: ContactAddedListener, + private val context: Context, private val conversationRepo: ConversationRepository, private val deleteConversations: DeleteConversations, private val markArchived: MarkArchived, @@ -52,7 +58,7 @@ class ConversationInfoPresenter @Inject constructor( private val navigator: Navigator, private val permissionManager: PermissionManager ) : QkPresenter( - ConversationInfoState(threadId = threadId, media = messageRepo.getPartsForConversation(threadId)) + ConversationInfoState(threadId = threadId) ) { private val conversation: Subject = BehaviorSubject.create() @@ -74,29 +80,29 @@ class ConversationInfoPresenter @Inject constructor( disposables += markUnarchived disposables += deleteConversations - // Update the recipients whenever they change - disposables += conversation - .map { conversation -> conversation.recipients } - .distinctUntilChanged() - .subscribe { recipients -> newState { copy(recipients = recipients) } } + disposables += Observables + .combineLatest( + conversation, + messageRepo.getPartsForConversation(threadId).asObservable() + ) { conversation, parts -> + val data = mutableListOf() - // Update conversation title whenever it changes - disposables += conversation - .map { conversation -> conversation.name } - .distinctUntilChanged() - .subscribe { name -> newState { copy(name = name) } } - - // Update the view's archived state whenever it changes - disposables += conversation - .map { conversation -> conversation.archived } - .distinctUntilChanged() - .subscribe { archived -> newState { copy(archived = archived) } } - - // Update the view's blocked state whenever it changes - disposables += conversation - .map { conversation -> conversation.blocked } - .distinctUntilChanged() - .subscribe { blocked -> newState { copy(blocked = blocked) } } + // If some data was deleted, this isn't the place to handle it + if (!conversation.isLoaded || !conversation.isValid || !parts.isLoaded || !parts.isValid) { + return@combineLatest + } + + data += conversation.recipients.map(::ConversationInfoRecipient) + data += ConversationInfoItem.ConversationInfoSettings( + name = conversation.name, + recipients = conversation.recipients, + archived = conversation.archived, + blocked = conversation.blocked) + data += parts.map(::ConversationInfoMedia) + + newState { copy(data = data) } + } + .subscribe() } override fun bindIntents(view: ConversationInfoView) { @@ -105,20 +111,29 @@ class ConversationInfoPresenter @Inject constructor( // Add or display the contact view.recipientClicks() .mapNotNull(conversationRepo::getRecipient) - .flatMap { recipient -> - val lookupKey = recipient.contact?.lookupKey - if (lookupKey != null) { - navigator.showContact(lookupKey) - Observable.empty() - } else { - // Allow the user to add the contact, then listen for changes - navigator.addContact(recipient.address) - contactAddedListener.listen(recipient.address) - } + .doOnNext { recipient -> + recipient.contact?.lookupKey?.let(navigator::showContact) + ?: navigator.addContact(recipient.address) } .autoDisposable(view.scope(Lifecycle.Event.ON_DESTROY)) // ... this should be the default .subscribe() + // Copy phone number + view.recipientLongClicks() + .mapNotNull(conversationRepo::getRecipient) + .map { recipient -> recipient.address } + .observeOn(AndroidSchedulers.mainThread()) + .autoDisposable(view.scope()) + .subscribe { address -> + ClipboardUtils.copy(context, address) + context.makeToast(R.string.info_copied_address) + } + + // Show the theme settings for the conversation + view.themeClicks() + .autoDisposable(view.scope()) + .subscribe(view::showThemePicker) + // Show the conversation title dialog view.nameClicks() .withLatestFrom(conversation) { _, conversation -> conversation } @@ -140,12 +155,6 @@ class ConversationInfoPresenter @Inject constructor( .autoDisposable(view.scope()) .subscribe { conversation -> navigator.showNotificationSettings(conversation.id) } - // Show the theme settings for the conversation - view.themeClicks() - .withLatestFrom(conversation) { _, conversation -> conversation } - .autoDisposable(view.scope()) - .subscribe { conversation -> view.showThemePicker(conversation.id) } - // Toggle the archived state of the conversation view.archiveClicks() .withLatestFrom(conversation) { _, conversation -> conversation } @@ -174,6 +183,11 @@ class ConversationInfoPresenter @Inject constructor( .withLatestFrom(conversation) { _, conversation -> conversation } .autoDisposable(view.scope()) .subscribe { conversation -> deleteConversations.execute(listOf(conversation.id)) } + + // Media + view.mediaClicks() + .autoDisposable(view.scope()) + .subscribe(navigator::showMedia) } } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoState.kt index cbda9f47e226b436b66f08ba9d1867d1156deb3c..693a862eea643a55174840c6be5db2ef4685047d 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoState.kt @@ -18,17 +18,8 @@ */ package com.moez.QKSMS.feature.conversationinfo -import com.moez.QKSMS.model.MmsPart -import com.moez.QKSMS.model.Recipient -import io.realm.RealmList -import io.realm.RealmResults - data class ConversationInfoState( - val name: String = "", - val recipients: RealmList? = null, val threadId: Long = 0, - val archived: Boolean = false, - val blocked: Boolean = false, - val media: RealmResults? = null, + val data: List = listOf(), val hasError: Boolean = false -) \ No newline at end of file +) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoView.kt index e2c1d97136abc760168681f6c22e0e144ef05935..5a86ffffe42e01764f0d4c5662a9f5190ec55b17 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationInfoView.kt @@ -24,17 +24,19 @@ import io.reactivex.Observable interface ConversationInfoView : QkViewContract { fun recipientClicks(): Observable + fun recipientLongClicks(): Observable + fun themeClicks(): Observable fun nameClicks(): Observable<*> fun nameChanges(): Observable fun notificationClicks(): Observable<*> - fun themeClicks(): Observable<*> fun archiveClicks(): Observable<*> fun blockClicks(): Observable<*> fun deleteClicks(): Observable<*> fun confirmDelete(): Observable<*> + fun mediaClicks(): Observable fun showNameDialog(name: String) - fun showThemePicker(threadId: Long) + fun showThemePicker(recipientId: Long) fun showBlockingDialog(conversations: List, block: Boolean) fun requestDefaultSms() fun showDeleteDialog() diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationMediaAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationMediaAdapter.kt deleted file mode 100644 index e34063c0f3b1b2119db179b2874320b2ba5cc0ea..0000000000000000000000000000000000000000 --- a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationMediaAdapter.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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 com.moez.QKSMS.feature.conversationinfo - -import android.content.Context -import android.view.LayoutInflater -import android.view.ViewGroup -import com.moez.QKSMS.R -import com.moez.QKSMS.common.Navigator -import com.moez.QKSMS.common.base.QkRealmAdapter -import com.moez.QKSMS.common.base.QkViewHolder -import com.moez.QKSMS.common.util.extensions.setVisible -import com.moez.QKSMS.extensions.isVideo -import com.moez.QKSMS.model.MmsPart -import com.moez.QKSMS.util.GlideApp -import kotlinx.android.synthetic.main.conversation_media_list_item.view.* -import javax.inject.Inject - -class ConversationMediaAdapter @Inject constructor( - private val context: Context, - private val navigator: Navigator -) : QkRealmAdapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { - val inflater = LayoutInflater.from(parent.context) - val view = inflater.inflate(R.layout.conversation_media_list_item, parent, false) - return QkViewHolder(view).apply { - view.thumbnail.setOnClickListener { - val part = getItem(adapterPosition) ?: return@setOnClickListener - navigator.showMedia(part.id) - } - } - } - - override fun onBindViewHolder(holder: QkViewHolder, position: Int) { - val part = getItem(position) ?: return - val view = holder.containerView - - GlideApp.with(context) - .load(part.getUri()) - .fitCenter() - .into(view.thumbnail) - - view.video.setVisible(part.isVideo()) - } - -} \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationRecipientAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationRecipientAdapter.kt deleted file mode 100644 index f98f51bd24f4552849bae3d6d30100957ad56e56..0000000000000000000000000000000000000000 --- a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/ConversationRecipientAdapter.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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 com.moez.QKSMS.feature.conversationinfo - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.moez.QKSMS.R -import com.moez.QKSMS.common.base.QkRealmAdapter -import com.moez.QKSMS.common.base.QkViewHolder -import com.moez.QKSMS.common.util.extensions.setVisible -import com.moez.QKSMS.model.Recipient -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.subjects.PublishSubject -import io.reactivex.subjects.Subject -import kotlinx.android.synthetic.main.conversation_recipient_list_item.view.* -import javax.inject.Inject - -class ConversationRecipientAdapter @Inject constructor() : QkRealmAdapter() { - - var threadId: Long = 0L - val clicks: Subject = PublishSubject.create() - - private val disposables = CompositeDisposable() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { - val layoutInflater = LayoutInflater.from(parent.context) - val view = layoutInflater.inflate(R.layout.conversation_recipient_list_item, parent, false) - return QkViewHolder(view).apply { - view.setOnClickListener { - val recipient = getItem(adapterPosition) ?: return@setOnClickListener - clicks.onNext(recipient.id) - } - } - } - - override fun onBindViewHolder(holder: QkViewHolder, position: Int) { - val recipient = getItem(position) ?: return - val view = holder.containerView - - view.avatar.threadId = threadId - view.avatar.setContact(recipient) - - view.name.text = recipient.contact?.name ?: recipient.address - - view.address.text = recipient.address - view.address.setVisible(recipient.contact != null) - - view.add.setVisible(recipient.contact == null) - } - - override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { - super.onDetachedFromRecyclerView(recyclerView) - disposables.clear() - } - -} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/GridSpacingItemDecoration.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/GridSpacingItemDecoration.kt index 6c4b4e71c5b9dfcf57b94048cae0af6e42e046b1..41bfdf90644c1f8ed7ecb2144e3f665fa83a4007 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/GridSpacingItemDecoration.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/conversationinfo/GridSpacingItemDecoration.kt @@ -23,9 +23,13 @@ import android.graphics.Rect import android.view.View import androidx.recyclerview.widget.RecyclerView import com.moez.QKSMS.common.util.extensions.dpToPx -import javax.inject.Inject +import com.moez.QKSMS.feature.conversationinfo.ConversationInfoItem.ConversationInfoMedia +import com.moez.QKSMS.feature.conversationinfo.ConversationInfoItem.ConversationInfoRecipient -class GridSpacingItemDecoration @Inject constructor(context: Context) : RecyclerView.ItemDecoration() { +class GridSpacingItemDecoration( + private val adapter: ConversationInfoAdapter, + private val context: Context +) : RecyclerView.ItemDecoration() { private val spanCount = 3 private val spacing = 2.dpToPx(context) @@ -34,13 +38,19 @@ class GridSpacingItemDecoration @Inject constructor(context: Context) : Recycler super.getItemOffsets(outRect, view, parent, state) val position = parent.getChildAdapterPosition(view) - val column = position % spanCount + val item = adapter.getItem(position) - outRect.left = column * spacing / spanCount - outRect.right = spacing - (column + 1) * spacing / spanCount + if (item is ConversationInfoRecipient && adapter.getItem(position + 1) !is ConversationInfoRecipient) { + outRect.bottom = 8.dpToPx(context) + } else if (item is ConversationInfoMedia) { + val firstPartIndex = adapter.data.indexOfFirst { it is ConversationInfoMedia } + val localPartIndex = position - firstPartIndex + + val column = localPartIndex % spanCount - if (position >= spanCount) { outRect.top = spacing + outRect.left = column * spacing / spanCount + outRect.right = spacing - (column + 1) * spacing / spanCount } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt index f35caac4bda89878bfa7b3a90190c8255a8ec09d..8f1c004102832020c69ef84c940eb97fb8fc57c5 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt @@ -32,6 +32,8 @@ import com.moez.QKSMS.common.util.DateFormatter import com.moez.QKSMS.common.util.extensions.resolveThemeColor import com.moez.QKSMS.common.util.extensions.setTint import com.moez.QKSMS.model.Conversation +import com.moez.QKSMS.util.PhoneNumberUtils +import kotlinx.android.synthetic.main.conversation_list_item.* import kotlinx.android.synthetic.main.conversation_list_item.view.* import javax.inject.Inject @@ -39,10 +41,12 @@ class ConversationsAdapter @Inject constructor( private val colors: Colors, private val context: Context, private val dateFormatter: DateFormatter, - private val navigator: Navigator + private val navigator: Navigator, + private val phoneNumberUtils: PhoneNumberUtils ) : QkRealmAdapter() { init { + // This is how we access the threadId for the swipe actions setHasStableIds(true) } @@ -60,7 +64,6 @@ class ConversationsAdapter @Inject constructor( view.snippet.maxLines = 5 view.unread.isVisible = true - view.unread.setTint(colors.theme().theme) view.date.setTypeface(view.date.typeface, Typeface.BOLD) view.date.setTextColor(textColorPrimary) @@ -83,29 +86,39 @@ class ConversationsAdapter @Inject constructor( } } - override fun onBindViewHolder(viewHolder: QkViewHolder, position: Int) { + override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val conversation = getItem(position) ?: return - val view = viewHolder.containerView - view.isActivated = isSelected(conversation.id) + holder.containerView.isActivated = isSelected(conversation.id) - view.avatars.contacts = conversation.recipients - view.title.collapseEnabled = conversation.recipients.size > 1 - view.title.text = conversation.getTitle() - view.date.text = dateFormatter.getConversationTimestamp(conversation.date) - view.snippet.text = when { + holder.avatars.recipients = conversation.recipients + holder.title.collapseEnabled = conversation.recipients.size > 1 + holder.title.text = conversation.getTitle() + holder.date.text = conversation.date.takeIf { it > 0 }?.let(dateFormatter::getConversationTimestamp) + holder.snippet.text = when { conversation.draft.isNotEmpty() -> context.getString(R.string.main_draft, conversation.draft) conversation.me -> context.getString(R.string.main_sender_you, conversation.snippet) else -> conversation.snippet } - view.pinned.isVisible = conversation.pinned + holder.pinned.isVisible = conversation.pinned + + // If the last message wasn't incoming, then the colour doesn't really matter anyway + val lastMessage = conversation.lastMessage + val recipient = when { + conversation.recipients.size == 1 || lastMessage == null -> conversation.recipients.firstOrNull() + else -> conversation.recipients.find { recipient -> + phoneNumberUtils.compare(recipient.address, lastMessage.address) + } + } + + holder.unread.setTint(colors.theme(recipient).theme) } - override fun getItemId(index: Int): Long { - return getItem(index)!!.id + override fun getItemId(position: Int): Long { + return getItem(position)?.id ?: -1 } override fun getItemViewType(position: Int): Int { return if (getItem(position)?.unread == false) 0 else 1 } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryActivity.kt index 868fd3c2dd56a567f0dce253d12e1936abb6edc7..ff6427c6a0bc6524342c9e27fc387100e21ca1b5 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryActivity.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryActivity.kt @@ -56,7 +56,7 @@ class GalleryActivity : QkActivity(), GalleryView { private val permissionResultSubject: Subject = PublishSubject.create() override fun onCreate(savedInstanceState: Bundle?) { - delegate.setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES) + delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES AndroidInjection.inject(this) super.onCreate(savedInstanceState) setContentView(R.layout.gallery_activity) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryPagerAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryPagerAdapter.kt index 2569a6c075280ae567ca5078b9981295c732e7db..f3a4405bcfa53c2f0b5ccdf3b8b0c96efd908687 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryPagerAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryPagerAdapter.kt @@ -39,8 +39,9 @@ import com.moez.QKSMS.model.MmsPart import com.moez.QKSMS.util.GlideApp import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject +import kotlinx.android.synthetic.main.gallery_image_page.* import kotlinx.android.synthetic.main.gallery_image_page.view.* -import kotlinx.android.synthetic.main.gallery_video_page.view.* +import kotlinx.android.synthetic.main.gallery_video_page.* import java.util.* import javax.inject.Inject @@ -90,7 +91,6 @@ class GalleryPagerAdapter @Inject constructor(private val context: Context) : Qk override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val part = getItem(position) ?: return - val view = holder.containerView when (getItemViewType(position)) { VIEW_TYPE_IMAGE -> { // We need to explicitly request a gif from glide for animations to work @@ -98,12 +98,12 @@ class GalleryPagerAdapter @Inject constructor(private val context: Context) : Qk ContentType.IMAGE_GIF -> GlideApp.with(context) .asGif() .load(part.getUri()) - .into(view.image) + .into(holder.image) else -> GlideApp.with(context) .asBitmap() .load(part.getUri()) - .into(view.image) + .into(holder.image) } } @@ -111,7 +111,7 @@ class GalleryPagerAdapter @Inject constructor(private val context: Context) : Qk val videoTrackSelectionFactory = AdaptiveTrackSelection.Factory(null) val trackSelector = DefaultTrackSelector(videoTrackSelectionFactory) val exoPlayer = ExoPlayerFactory.newSimpleInstance(context, trackSelector) - view.video.player = exoPlayer + holder.video.player = exoPlayer exoPlayers.add(exoPlayer) val dataSourceFactory = DefaultDataSourceFactory(context, Util.getUserAgent(context, "QKSMS")) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryViewModel.kt index 0e487a6b025841c8d1fa45e41d566e380b9c9456..5ece3841ba71bfdabd9f66dfc1dc3035e5d30bf1 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/gallery/GalleryViewModel.kt @@ -20,6 +20,7 @@ package com.moez.QKSMS.feature.gallery import android.content.Context import com.moez.QKSMS.R +import com.moez.QKSMS.common.Navigator import com.moez.QKSMS.common.base.QkViewModel import com.moez.QKSMS.common.util.extensions.makeToast import com.moez.QKSMS.extensions.mapNotNull @@ -37,11 +38,12 @@ import javax.inject.Named class GalleryViewModel @Inject constructor( conversationRepo: ConversationRepository, - messageRepo: MessageRepository, @Named("partId") private val partId: Long, private val context: Context, + private val messageRepo: MessageRepository, + private val navigator: Navigator, private val saveImage: SaveImage, - private val permissionManager: PermissionManager + private val permissions: PermissionManager ) : QkViewModel(GalleryState()) { init { @@ -60,10 +62,10 @@ class GalleryViewModel @Inject constructor( override fun bindView(view: GalleryView) { super.bindView(view) view.permissionResult() - .map { permissionManager.hasStorage() } + .map { permissions.hasStorage() } .autoDisposable(view.scope()) .subscribe{ - if(permissionManager.hasStorage()) + if(permissions.hasStorage()) saveImage.execute(partId) { context.makeToast(R.string.gallery_toast_saved) } } // When the screen is touched, toggle the visibility of the navigation UI @@ -76,16 +78,23 @@ class GalleryViewModel @Inject constructor( // Save image to device view.optionsItemSelected() .filter { itemId -> itemId == R.id.save } - .filter { permissionManager.hasStorage().also { if (!it) view.requestStoragePermission() } } + .filter { permissions.hasStorage().also { if (!it) view.requestStoragePermission() } } .withLatestFrom(view.pageChanged()) { _, part -> part.id } .autoDisposable(view.scope()) .subscribe { partId -> - if(permissionManager.hasStorage()) + if(permissions.hasStorage()) saveImage.execute(partId) { context.makeToast(R.string.gallery_toast_saved) } else view.requestStoragePermission() } + // Share image externally + view.optionsItemSelected() + .filter { itemId -> itemId == R.id.share } + .filter { permissions.hasStorage().also { if (!it) view.requestStoragePermission() } } + .withLatestFrom(view.pageChanged()) { _, part -> part.id } + .autoDisposable(view.scope()) + .subscribe { partId -> messageRepo.savePart(partId)?.let(navigator::shareFile) } } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainActivity.kt index 4f26995cfe1b6ea992058ca2df878d5149916d06..3fdc07ec04dadedb19c62dd463ffade9f7a0f3de 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainActivity.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainActivity.kt @@ -23,6 +23,7 @@ import android.animation.ObjectAnimator import android.app.AlertDialog import android.content.Intent import android.content.res.ColorStateList +import android.os.Build import android.os.Bundle import android.view.Gravity import android.view.Menu @@ -33,10 +34,10 @@ import androidx.appcompat.app.ActionBarDrawerToggle import androidx.core.app.ActivityCompat import androidx.core.view.GravityCompat import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProviders import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.snackbar.Snackbar import com.jakewharton.rxbinding2.view.clicks import com.jakewharton.rxbinding2.widget.textChanges @@ -82,7 +83,7 @@ class MainActivity : QkThemedActivity(), MainView { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory override val onNewIntentIntent: Subject = PublishSubject.create() - override val activityResumedIntent: Subject = PublishSubject.create() + override val activityResumedIntent: Subject = PublishSubject.create() override val queryChangedIntent by lazy { toolbarSearch.textChanges() } override val composeIntent by lazy { compose.clicks() } override val drawerOpenIntent: Observable by lazy { @@ -128,7 +129,9 @@ class MainActivity : QkThemedActivity(), MainView { onNewIntentIntent.onNext(intent) (snackbar as? ViewStub)?.setOnInflateListener { _, _ -> - snackbarButton.clicks().autoDisposable(scope()).subscribe(snackbarButtonIntent) + snackbarButton.clicks() + .autoDisposable(scope(Lifecycle.Event.ON_DESTROY)) + .subscribe(snackbarButtonIntent) } (syncing as? ViewStub)?.setOnInflateListener { _, _ -> @@ -142,20 +145,21 @@ class MainActivity : QkThemedActivity(), MainView { homeIntent.onNext(Unit) } - recyclerView.setHasFixedSize(true) - recyclerView.layoutManager = LinearLayoutManager(this) + itemTouchCallback.adapter = conversationsAdapter + conversationsAdapter.autoScrollToStart(recyclerView) // Don't allow clicks to pass through the drawer layout drawer.clicks().autoDisposable(scope()).subscribe() // Set the theme color tint to the recyclerView, progressbar, and FAB theme - .doOnNext { recyclerView.scrapViews() } .autoDisposable(scope()) .subscribe { theme -> // Set the color for the drawer icons - val states = arrayOf(intArrayOf(android.R.attr.state_activated), + val states = arrayOf( + intArrayOf(android.R.attr.state_activated), intArrayOf(-android.R.attr.state_activated)) + resolveThemeColor(android.R.attr.textColorSecondary) .let { textSecondary -> ColorStateList(states, intArrayOf(theme.theme, textSecondary)) } .let { tintList -> @@ -172,8 +176,10 @@ class MainActivity : QkThemedActivity(), MainView { compose.setTint(theme.textPrimary) } - itemTouchCallback.adapter = conversationsAdapter - conversationsAdapter.autoScrollToStart(recyclerView) + // These theme attributes don't apply themselves on API 21 + if (Build.VERSION.SDK_INT <= 22) { + toolbarSearch.setBackgroundTint(resolveThemeColor(R.attr.bubbleColor)) + } } override fun onNewIntent(intent: Intent?) { @@ -261,10 +267,11 @@ class MainActivity : QkThemedActivity(), MainView { inbox.isActivated = state.page is Inbox archived.isActivated = state.page is Archived - if (drawerLayout.isDrawerOpen(GravityCompat.START) && !state.drawerOpen) drawerLayout.closeDrawer( - GravityCompat.START) - else if (!drawerLayout.isDrawerVisible(GravityCompat.START) && state.drawerOpen) drawerLayout.openDrawer( - GravityCompat.START) + if (drawerLayout.isDrawerOpen(GravityCompat.START) && !state.drawerOpen) { + drawerLayout.closeDrawer(GravityCompat.START) + } else if (!drawerLayout.isDrawerVisible(GravityCompat.START) && state.drawerOpen) { + drawerLayout.openDrawer(GravityCompat.START) + } when (state.syncing) { is SyncRepository.SyncProgress.Idle -> { @@ -304,7 +311,12 @@ class MainActivity : QkThemedActivity(), MainView { override fun onResume() { super.onResume() - activityResumedIntent.onNext(Unit) + activityResumedIntent.onNext(true) + } + + override fun onPause() { + super.onPause() + activityResumedIntent.onNext(false) } override fun onDestroy() { @@ -340,6 +352,10 @@ class MainActivity : QkThemedActivity(), MainView { conversationsAdapter.clearSelection() } + override fun themeChanged() { + recyclerView.scrapViews() + } + override fun showBlockingDialog(conversations: List, block: Boolean) { blockingDialog.show(this, conversations, block) } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainState.kt index 39cf39aebddd2f39f3f04a5d30a3964602aa8b40..432ec2dcd195d2958ee114233a272e132435c525 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainState.kt @@ -28,7 +28,7 @@ data class MainState( val page: MainPage = Inbox(), val drawerOpen: Boolean = false, val showRating: Boolean = false, - val syncing: SyncRepository.SyncProgress = SyncRepository.SyncProgress.Idle(), + val syncing: SyncRepository.SyncProgress = SyncRepository.SyncProgress.Idle, val defaultSms: Boolean = true, val smsPermission: Boolean = true, val contactPermission: Boolean = true diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainView.kt index 79ad8aef56e69b3bd84d5f36b197d4daa1e8e4c9..ab885230395fc5249cf82fb87ab47f40eee66575 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainView.kt @@ -26,7 +26,7 @@ import io.reactivex.Observable interface MainView : QkView { val onNewIntentIntent: Observable - val activityResumedIntent: Observable<*> + val activityResumedIntent: Observable val queryChangedIntent: Observable val composeIntent: Observable val drawerOpenIntent: Observable @@ -44,6 +44,7 @@ interface MainView : QkView { fun requestPermissions() fun clearSearch() fun clearSelection() + fun themeChanged() fun showBlockingDialog(conversations: List, block: Boolean) fun showDeleteDialog(conversations: List) fun showChangelog(changelog: ChangelogManager.Changelog) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainViewModel.kt index 36156119cfed32ce96b7a8dd3db5d01150c08d79..c041ce086f4ffaecbe0d5bea22a905b4935324d4 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/main/MainViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/main/MainViewModel.kt @@ -25,7 +25,17 @@ import com.moez.QKSMS.common.Navigator import com.moez.QKSMS.common.base.QkViewModel import com.moez.QKSMS.common.util.BillingManager import com.moez.QKSMS.extensions.mapNotNull -import com.moez.QKSMS.interactor.* +import com.moez.QKSMS.interactor.DeleteConversations +import com.moez.QKSMS.interactor.MarkAllSeen +import com.moez.QKSMS.interactor.MarkArchived +import com.moez.QKSMS.interactor.MarkPinned +import com.moez.QKSMS.interactor.MarkRead +import com.moez.QKSMS.interactor.MarkUnarchived +import com.moez.QKSMS.interactor.MarkUnpinned +import com.moez.QKSMS.interactor.MarkUnread +import com.moez.QKSMS.interactor.MigratePreferences +import com.moez.QKSMS.interactor.SyncContacts +import com.moez.QKSMS.interactor.SyncMessages import com.moez.QKSMS.listener.ContactAddedListener import com.moez.QKSMS.manager.ChangelogManager import com.moez.QKSMS.manager.PermissionManager @@ -46,26 +56,26 @@ import javax.inject.Inject import javax.inject.Named class MainViewModel @Inject constructor( - @Named("threadId") private val threadId: Long, - markAllSeen: MarkAllSeen, - migratePreferences: MigratePreferences, - syncRepository: SyncRepository, - private val contactAddedListener: ContactAddedListener, - private val changelogManager: ChangelogManager, - private val conversationRepo: ConversationRepository, - private val deleteConversations: DeleteConversations, - private val markArchived: MarkArchived, - private val markBlocked: MarkBlocked, - private val markPinned: MarkPinned, - private val markRead: MarkRead, - private val markUnarchived: MarkUnarchived, - private val markUnpinned: MarkUnpinned, - private val markUnread: MarkUnread, - private val navigator: Navigator, - private val permissionManager: PermissionManager, - private val prefs: Preferences, - private val syncMessages: SyncMessages, - private val syncContacts: ContactSync + @Named("threadId") private val threadId: Long, + billingManager: BillingManager, + contactAddedListener: ContactAddedListener, + markAllSeen: MarkAllSeen, + migratePreferences: MigratePreferences, + syncRepository: SyncRepository, + private val changelogManager: ChangelogManager, + private val conversationRepo: ConversationRepository, + private val deleteConversations: DeleteConversations, + private val markArchived: MarkArchived, + private val markPinned: MarkPinned, + private val markRead: MarkRead, + private val markUnarchived: MarkUnarchived, + private val markUnpinned: MarkUnpinned, + private val markUnread: MarkUnread, + private val navigator: Navigator, + private val permissionManager: PermissionManager, + private val prefs: Preferences, + private val syncContacts: SyncContacts, + private val syncMessages: SyncMessages ) : QkViewModel(MainState(page = Inbox(data = conversationRepo.getConversations()))) { init { @@ -74,6 +84,7 @@ class MainViewModel @Inject constructor( disposables += markArchived disposables += markUnarchived disposables += migratePreferences + disposables += syncContacts disposables += syncMessages // Show the syncing UI @@ -95,6 +106,13 @@ class MainViewModel @Inject constructor( syncMessages.execute(Unit) } + // Sync contacts when we detect a change + if (permissionManager.hasContacts()) { + disposables += contactAddedListener.listen() + .debounce(1, TimeUnit.SECONDS) + .subscribeOn(Schedulers.io()) + .subscribe { syncContacts.execute(Unit) } + } markAllSeen.execute(Unit) } @@ -107,6 +125,7 @@ class MainViewModel @Inject constructor( } val permissions = view.activityResumedIntent + .filter { resumed -> resumed } .observeOn(Schedulers.io()) .map { Triple(permissionManager.isDefaultSms(), permissionManager.hasReadSms(), permissionManager.hasContacts()) } .distinctUntilChanged() @@ -178,6 +197,20 @@ class MainViewModel @Inject constructor( .autoDisposable(view.scope()) .subscribe { data -> newState { copy(page = Searching(loading = false, data = data)) } } + view.activityResumedIntent + .filter { resumed -> !resumed } + .switchMap { + // Take until the activity is resumed + prefs.keyChanges + .filter { key -> key.contains("theme") } + .map { true } + .mergeWith(prefs.autoColor.asObservable().skip(1)) + .doOnNext { view.themeChanged() } + .takeUntil(view.activityResumedIntent.filter { resumed -> resumed }) + } + .autoDisposable(view.scope()) + .subscribe() + view.composeIntent .autoDisposable(view.scope()) .subscribe { navigator.showCompose() } @@ -272,7 +305,6 @@ class MainViewModel @Inject constructor( .map { conversation -> conversation.recipients } .mapNotNull { recipients -> recipients[0]?.address?.takeIf { recipients.size == 1 } } .doOnNext(navigator::addContact) - .flatMap(contactAddedListener::listen) .autoDisposable(view.scope()) .subscribe() diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/main/SearchAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/main/SearchAdapter.kt index 6e429aa595b85d2f5dd1aef79df9e841ab6eef85..9a964a20877c88598a4748ca8581f7ba72b7efcc 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/main/SearchAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/main/SearchAdapter.kt @@ -33,6 +33,7 @@ import com.moez.QKSMS.common.util.DateFormatter import com.moez.QKSMS.common.util.extensions.setVisible import com.moez.QKSMS.extensions.removeAccents import com.moez.QKSMS.model.SearchResult +import kotlinx.android.synthetic.main.search_list_item.* import kotlinx.android.synthetic.main.search_list_item.view.* import javax.inject.Inject @@ -56,12 +57,11 @@ class SearchAdapter @Inject constructor( } } - override fun onBindViewHolder(viewHolder: QkViewHolder, position: Int) { + override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val previous = data.getOrNull(position - 1) val result = getItem(position) - val view = viewHolder.containerView - view.resultsHeader.setVisible(result.messages > 0 && previous?.messages == 0) + holder.resultsHeader.setVisible(result.messages > 0 && previous?.messages == 0) val query = result.query val title = SpannableString(result.conversation.getTitle()) @@ -71,23 +71,23 @@ class SearchAdapter @Inject constructor( title.setSpan(BackgroundColorSpan(highlightColor), index, index + query.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) index = title.indexOf(query, index + query.length, true) } - view.title.text = title + holder.title.text = title - view.avatars.contacts = result.conversation.recipients + holder.avatars.recipients = result.conversation.recipients when (result.messages == 0) { true -> { - view.date.setVisible(true) - view.date.text = dateFormatter.getConversationTimestamp(result.conversation.date) - view.snippet.text = when (result.conversation.me) { + holder.date.setVisible(true) + holder.date.text = dateFormatter.getConversationTimestamp(result.conversation.date) + holder.snippet.text = when (result.conversation.me) { true -> context.getString(R.string.main_sender_you, result.conversation.snippet) false -> result.conversation.snippet } } false -> { - view.date.setVisible(false) - view.snippet.text = context.getString(R.string.main_message_results, result.messages) + holder.date.setVisible(false) + holder.snippet.text = context.getString(R.string.main_message_results, result.messages) } } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsActivity.kt index b998c1d97a5f5c3c10cb3f68d7c5d375367af15f..210b5d3fe7b47edac3c88824f90aa134f5e52b76 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsActivity.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsActivity.kt @@ -98,6 +98,7 @@ class NotificationPrefsActivity : QkThemedActivity(), NotificationPrefsView { notifications.checkbox.isChecked = state.notificationsEnabled previews.summary = state.previewSummary previewModeDialog.adapter.selectedItem = state.previewId + wake.checkbox.isChecked = state.wakeEnabled vibration.checkbox.isChecked = state.vibrationEnabled ringtone.summary = state.ringtoneName diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsState.kt index e5e077ea249c00f63442a6225fef5890c8ad51d9..c0dcd0f24ca5b48030d2b4f58dddcc89e49dc705 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsState.kt @@ -27,6 +27,7 @@ data class NotificationPrefsState( val notificationsEnabled: Boolean = true, val previewSummary: String = "", val previewId: Int = Preferences.NOTIFICATION_PREVIEWS_ALL, + val wakeEnabled: Boolean = false, val action1Summary: String = "", val action2Summary: String = "", val action3Summary: String = "", diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsViewModel.kt index 6639d4f709829cd53cb55aa87acdb7e3fb609bcc..596423bb33b8bdcc31f8abe9d15c1a7aa281c68a 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/notificationprefs/NotificationPrefsViewModel.kt @@ -46,6 +46,7 @@ class NotificationPrefsViewModel @Inject constructor( private val notifications = prefs.notifications(threadId) private val previews = prefs.notificationPreviews(threadId) + private val wake = prefs.wakeScreen(threadId) private val vibration = prefs.vibration(threadId) private val ringtone = prefs.ringtone(threadId) @@ -75,6 +76,9 @@ class NotificationPrefsViewModel @Inject constructor( disposables += prefs.notifAction3.asObservable() .subscribe { previewId -> newState { copy(action3Summary = actionLabels[previewId]) } } + disposables += wake.asObservable() + .subscribe { enabled -> newState { copy(wakeEnabled = enabled) } } + disposables += vibration.asObservable() .subscribe { enabled -> newState { copy(vibrationEnabled = enabled) } } @@ -107,6 +111,8 @@ class NotificationPrefsViewModel @Inject constructor( R.id.previews -> view.showPreviewModeDialog() + R.id.wake -> wake.set(!wake.get()) + R.id.vibration -> vibration.set(!vibration.get()) R.id.ringtone -> view.showRingtonePicker(ringtone.get().takeIf { it.isNotEmpty() }?.let(Uri::parse)) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/qkreply/QkReplyActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/qkreply/QkReplyActivity.kt index ead2fe26e95b5654f0a74d321f21d0f76fee9f73..392196d414d5820258cee9b5e3c6944f963067d7 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/qkreply/QkReplyActivity.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/qkreply/QkReplyActivity.kt @@ -62,6 +62,7 @@ class QkReplyActivity : QkThemedActivity(), QkReplyView { setFinishOnTouchOutside(prefs.qkreplyTapDismiss.get()) setContentView(R.layout.qkreply_activity) + window.setBackgroundDrawable(null) window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) viewModel.bindView(this) @@ -77,10 +78,10 @@ class QkReplyActivity : QkThemedActivity(), QkReplyView { // These theme attributes don't apply themselves on API 21 if (Build.VERSION.SDK_INT <= 22) { toolbar.setBackgroundTint(resolveThemeColor(R.attr.colorPrimary)) - background.setBackgroundTint(resolveThemeColor(R.attr.composeBackground)) + background.setBackgroundTint(resolveThemeColor(android.R.attr.windowBackground)) messageBackground.setBackgroundTint(resolveThemeColor(R.attr.bubbleColor)) - composeBackgroundGradient.setBackgroundTint(resolveThemeColor(R.attr.composeBackground)) - composeBackgroundSolid.setBackgroundTint(resolveThemeColor(R.attr.composeBackground)) + composeBackgroundGradient.setBackgroundTint(resolveThemeColor(android.R.attr.windowBackground)) + composeBackgroundSolid.setBackgroundTint(resolveThemeColor(android.R.attr.windowBackground)) } } @@ -89,7 +90,7 @@ class QkReplyActivity : QkThemedActivity(), QkReplyView { finish() } - threadId.onNext(state.selectedConversation) + threadId.onNext(state.threadId) title = state.title diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/qkreply/QkReplyState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/qkreply/QkReplyState.kt index 573dde17e154611659dca8713a972d6f9f0ad048..34dae4c97b8f7265463d0cd88929ed86b298d205 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/qkreply/QkReplyState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/qkreply/QkReplyState.kt @@ -25,7 +25,7 @@ import io.realm.RealmResults data class QkReplyState( val hasError: Boolean = false, - val selectedConversation: Long = 0, + val threadId: Long = 0, val title: String = "", val expanded: Boolean = false, val data: Pair>? = null, diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/qkreply/QkReplyViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/qkreply/QkReplyViewModel.kt index d29c2068cf12fdda937c11f9d5f61fcaadcb6c12..4ef550cd15b780157a738e7cfe1ac7743fa9f54f 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/qkreply/QkReplyViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/qkreply/QkReplyViewModel.kt @@ -54,7 +54,7 @@ class QkReplyViewModel @Inject constructor( private val navigator: Navigator, private val sendMessage: SendMessage, private val subscriptionManager: SubscriptionManagerCompat -) : QkViewModel(QkReplyState(selectedConversation = threadId)) { +) : QkViewModel(QkReplyState(threadId = threadId)) { private val conversation by lazy { conversationRepo.getConversationAsync(threadId) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAdapter.kt index 43dfcab719596e596dc5d30fe9f21cf6c09eaf9a..23c09227cb20b8bebb26cc1412bf5b5927fe1a1e 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAdapter.kt @@ -18,6 +18,7 @@ */ package com.moez.QKSMS.feature.scheduled +import android.content.Context import android.net.Uri import android.view.LayoutInflater import android.view.ViewGroup @@ -34,10 +35,12 @@ import com.moez.QKSMS.repository.ContactRepository import com.moez.QKSMS.util.PhoneNumberUtils import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject +import kotlinx.android.synthetic.main.scheduled_message_list_item.* import kotlinx.android.synthetic.main.scheduled_message_list_item.view.* import javax.inject.Inject class ScheduledMessageAdapter @Inject constructor( + private val context: Context, private val contactRepo: ContactRepository, private val dateFormatter: DateFormatter, private val phoneNumberUtils: PhoneNumberUtils @@ -52,7 +55,7 @@ class ScheduledMessageAdapter @Inject constructor( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { val view = LayoutInflater.from(parent.context).inflate(R.layout.scheduled_message_list_item, parent, false) - view.attachments.adapter = ScheduledMessageAttachmentAdapter() + view.attachments.adapter = ScheduledMessageAttachmentAdapter(context) view.attachments.setRecycledViewPool(imagesViewPool) return QkViewHolder(view).apply { @@ -65,21 +68,20 @@ class ScheduledMessageAdapter @Inject constructor( override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val message = getItem(position) ?: return - val view = holder.containerView // GroupAvatarView only accepts recipients, so map the phone numbers to recipients - view.avatars.contacts = message.recipients.map { address -> Recipient(address = address) } + holder.avatars.recipients = message.recipients.map { address -> Recipient(address = address) } - view.recipients.text = message.recipients.joinToString(",") { address -> + holder.recipients.text = message.recipients.joinToString(",") { address -> contactCache[address]?.name?.takeIf { it.isNotBlank() } ?: address } - view.date.text = dateFormatter.getScheduledTimestamp(message.date) - view.body.text = message.body + holder.date.text = dateFormatter.getScheduledTimestamp(message.date) + holder.body.text = message.body - val adapter = view.attachments.adapter as ScheduledMessageAttachmentAdapter + val adapter = holder.attachments.adapter as ScheduledMessageAttachmentAdapter adapter.data = message.attachments.map(Uri::parse) - view.attachments.isVisible = message.attachments.isNotEmpty() + holder.attachments.isVisible = message.attachments.isNotEmpty() } /** @@ -102,4 +104,4 @@ class ScheduledMessageAdapter @Inject constructor( } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAttachmentAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAttachmentAdapter.kt index 8b4e8838dfa46e6197ab514d8505030dd2050024..558c8af7ac7c73e04f03e7f70d8d73863c60f36e 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAttachmentAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAttachmentAdapter.kt @@ -18,6 +18,7 @@ */ package com.moez.QKSMS.feature.scheduled +import android.content.Context import android.net.Uri import android.view.LayoutInflater import android.view.ViewGroup @@ -26,9 +27,12 @@ import com.moez.QKSMS.common.base.QkAdapter import com.moez.QKSMS.common.base.QkViewHolder import com.moez.QKSMS.util.GlideApp import kotlinx.android.synthetic.main.attachment_image_list_item.view.* +import kotlinx.android.synthetic.main.scheduled_message_image_list_item.* import javax.inject.Inject -class ScheduledMessageAttachmentAdapter @Inject constructor() : QkAdapter() { +class ScheduledMessageAttachmentAdapter @Inject constructor( + private val context: Context +) : QkAdapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { val view = LayoutInflater.from(parent.context).inflate(R.layout.scheduled_message_image_list_item, parent, false) @@ -39,9 +43,8 @@ class ScheduledMessageAttachmentAdapter @Inject constructor() : QkAdapter() override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val attachment = getItem(position) - val view = holder.containerView - GlideApp.with(view).load(attachment).into(view.thumbnail) + GlideApp.with(context).load(attachment).into(holder.thumbnail) } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/settings/SettingsController.kt b/presentation/src/main/java/com/moez/QKSMS/feature/settings/SettingsController.kt index 0928402700eb85280d2999136ca374a7ba86ad2a..f7629b0bd04056dd0b2167c753356b889f099a13 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/settings/SettingsController.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/settings/SettingsController.kt @@ -160,10 +160,14 @@ class SettingsController : QkController(SettingsState( nightModeId = prefs.nightMode.get() )) { @@ -110,6 +112,9 @@ class SettingsPresenter @Inject constructor( } } + disposables += prefs.autoColor.asObservable() + .subscribe { autoColor -> newState { copy(autoColor = autoColor) } } + disposables += prefs.systemFont.asObservable() .subscribe { enabled -> newState { copy(systemFontEnabled = enabled) } } @@ -119,6 +124,9 @@ class SettingsPresenter @Inject constructor( disposables += prefs.mobileOnly.asObservable() .subscribe { enabled -> newState { copy(mobileOnly = enabled) } } + disposables += prefs.longAsMms.asObservable() + .subscribe { enabled -> newState { copy(longAsMms = enabled) } } + val mmsSizeLabels = context.resources.getStringArray(R.array.mms_sizes) val mmsSizeIds = context.resources.getIntArray(R.array.mms_sizes_ids) disposables += prefs.mmsSize.asObservable() @@ -178,12 +186,19 @@ class SettingsPresenter @Inject constructor( R.id.textSize -> view.showTextSizePicker() + R.id.autoColor -> { + analytics.setUserProperty("Preference: Auto Color", !prefs.autoColor.get()) + prefs.autoColor.set(!prefs.autoColor.get()) + } + R.id.systemFont -> prefs.systemFont.set(!prefs.systemFont.get()) R.id.unicode -> prefs.unicode.set(!prefs.unicode.get()) R.id.mobileOnly -> prefs.mobileOnly.set(!prefs.mobileOnly.get()) + R.id.longAsMms -> prefs.longAsMms.set(!prefs.longAsMms.get()) + R.id.mmsSize -> view.showMmsSizePicker() R.id.sync -> syncMessages.execute(Unit) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/settings/SettingsState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/settings/SettingsState.kt index d2a35995782029a6beb807924aaff0b495ad1411..f18ccd800e8357046c22fa0bbd46f81dd112064b 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/settings/SettingsState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/settings/SettingsState.kt @@ -28,6 +28,7 @@ data class SettingsState( val nightStart: String = "", val nightEnd: String = "", val black: Boolean = false, + val autoColor: Boolean = true, val autoEmojiEnabled: Boolean = true, val notificationsEnabled: Boolean = true, val sendDelaySummary: String = "", @@ -40,7 +41,8 @@ data class SettingsState( val splitSmsEnabled: Boolean = false, val stripUnicodeEnabled: Boolean = false, val mobileOnly: Boolean = false, + val longAsMms: Boolean = false, val maxMmsSizeSummary: String = "100KB", val maxMmsSizeId: Int = 100, - val syncProgress: SyncRepository.SyncProgress = SyncRepository.SyncProgress.Idle() + val syncProgress: SyncRepository.SyncProgress = SyncRepository.SyncProgress.Idle ) \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/HSVPickerView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/HSVPickerView.kt index aec322ca93919b1c61e651144bf3a5e73e86821c..15522443354ed0227071e28bf998a1cfe2fc080c 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/HSVPickerView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/HSVPickerView.kt @@ -33,7 +33,9 @@ import io.reactivex.subjects.BehaviorSubject import io.reactivex.subjects.Subject import kotlinx.android.synthetic.main.hsv_picker_view.view.* -class HSVPickerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : ConstraintLayout(context, attrs) { +class HSVPickerView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : ConstraintLayout(context, attrs) { val selectedColor: Subject = BehaviorSubject.create() diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemeAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemeAdapter.kt index 7489a34ad7b53d436da9b2a81f9caa09331fcf44..cf99614e74136756fd1c4dbb7e90c00a0d348ac9 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemeAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemeAdapter.kt @@ -36,6 +36,7 @@ import com.moez.QKSMS.common.util.extensions.setVisible import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject import kotlinx.android.synthetic.main.theme_list_item.view.* +import kotlinx.android.synthetic.main.theme_palette_list_item.* import kotlinx.android.synthetic.main.theme_palette_list_item.view.* import javax.inject.Inject @@ -70,7 +71,6 @@ class ThemeAdapter @Inject constructor( override fun onBindViewHolder(holder: QkViewHolder, position: Int) { val palette = getItem(position) - val view = holder.containerView val screenWidth = Resources.getSystem().displayMetrics.widthPixels val minPadding = (16 * 6).dpToPx(context) @@ -81,12 +81,12 @@ class ThemeAdapter @Inject constructor( } val swatchPadding = (screenWidth - size * 5) / 12 - view.palette.removeAllViews() - view.palette.setPadding(swatchPadding, swatchPadding, swatchPadding, swatchPadding) + holder.palette.removeAllViews() + holder.palette.setPadding(swatchPadding, swatchPadding, swatchPadding, swatchPadding) (palette.subList(0, 5) + palette.subList(5, 10).reversed()) .mapIndexed { index, color -> - LayoutInflater.from(context).inflate(R.layout.theme_list_item, view.palette, false).apply { + LayoutInflater.from(context).inflate(R.layout.theme_list_item, holder.palette, false).apply { // Send clicks to the selected subject setOnClickListener { colorSelected.onNext(color) } @@ -107,7 +107,7 @@ class ThemeAdapter @Inject constructor( } } } - .forEach { theme -> view.palette.addView(theme) } + .forEach { theme -> holder.palette.addView(theme) } } } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerController.kt b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerController.kt index 1ef80a3b5b40f30e8de3ef5e86cd12dd4a7d9b3c..ae4dc56f21e70fe9fb6fa8f7beec8dd505d2c6c5 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerController.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerController.kt @@ -38,7 +38,9 @@ import kotlinx.android.synthetic.main.theme_picker_controller.* import kotlinx.android.synthetic.main.theme_picker_hsv.* import javax.inject.Inject -class ThemePickerController(val threadId: Long = 0L) : QkController(), ThemePickerView { +class ThemePickerController( + val recipientId: Long = 0L +) : QkController(), ThemePickerView { @Inject override lateinit var presenter: ThemePickerPresenter @@ -107,7 +109,7 @@ class ThemePickerController(val threadId: Long = 0L) : QkController = viewQksmsPlusSubject override fun render(state: ThemePickerState) { - tabs.setThreadId(state.threadId) + tabs.setRecipientId(state.recipientId) hex.setText(Integer.toHexString(state.newColor).takeLast(6)) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerPresenter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerPresenter.kt index 20c7e358b878d0b6e971e3d58bec0977eca6f1ab..835695c763bbf808d75e1d9d214f851a37baa8e8 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerPresenter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerPresenter.kt @@ -21,6 +21,7 @@ package com.moez.QKSMS.feature.themepicker import com.f2prateek.rx.preferences2.Preference import com.moez.QKSMS.common.Navigator import com.moez.QKSMS.common.base.QkPresenter +import com.moez.QKSMS.common.util.BillingManager import com.moez.QKSMS.common.util.Colors import com.moez.QKSMS.manager.WidgetManager import com.moez.QKSMS.util.Preferences @@ -33,13 +34,14 @@ import javax.inject.Named class ThemePickerPresenter @Inject constructor( prefs: Preferences, - @Named("threadId") private val threadId: Long, + @Named("recipientId") private val recipientId: Long, + private val billingManager: BillingManager, private val colors: Colors, private val navigator: Navigator, private val widgetManager: WidgetManager -) : QkPresenter(ThemePickerState(threadId = threadId)) { +) : QkPresenter(ThemePickerState(recipientId = recipientId)) { - private val theme: Preference = prefs.theme(threadId) + private val theme: Preference = prefs.theme(recipientId) override fun bindIntents(view: ThemePickerView) { super.bindIntents(view) @@ -53,7 +55,7 @@ class ThemePickerPresenter @Inject constructor( .autoDisposable(view.scope()) .subscribe { color -> theme.set(color) - if (threadId == 0L) { + if (recipientId == 0L) { widgetManager.updateTheme() } } @@ -75,7 +77,7 @@ class ThemePickerPresenter @Inject constructor( view.applyHsvThemeClicks() .withLatestFrom(view.hsvThemeSelected()) { _, color -> theme.set(color) - if (threadId == 0L) { + if (recipientId == 0L) { widgetManager.updateTheme() } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerState.kt index 2aa731a0bdd149b35a2453fc9486934beef5a368..81c44dcf1154ddf8b8b53819f2548561122657e1 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerState.kt @@ -19,7 +19,7 @@ package com.moez.QKSMS.feature.themepicker data class ThemePickerState( - val threadId: Long = 0, + val recipientId: Long = 0, val applyThemeVisible: Boolean = false, val newColor: Int = -1, val newTextColor: Int = -1 diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/injection/ThemePickerModule.kt b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/injection/ThemePickerModule.kt index c320bdfc2d4a5c78c3de2f382af28adb73a3b899..c5c9b8d15a18d83c07491fd667c69b7332703162 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/injection/ThemePickerModule.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/injection/ThemePickerModule.kt @@ -29,7 +29,7 @@ class ThemePickerModule(private val controller: ThemePickerController) { @Provides @ControllerScope - @Named("threadId") - fun provideThreadId(): Long = controller.threadId + @Named("recipientId") + fun provideThreadId(): Long = controller.recipientId } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetAdapter.kt index 4b5a3d1d4dbf78cb112654dbae664ec14c9bb904..f8011ef8a8cb57c0acdad860f29801640c674381 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetAdapter.kt @@ -40,6 +40,7 @@ import com.moez.QKSMS.model.PhoneNumber import com.moez.QKSMS.repository.ConversationRepository import com.moez.QKSMS.util.GlideApp import com.moez.QKSMS.util.Preferences +import com.moez.QKSMS.util.tryOrNull import javax.inject.Inject class WidgetAdapter(intent: Intent) : RemoteViewsService.RemoteViewsFactory { @@ -130,30 +131,29 @@ class WidgetAdapter(intent: Intent) : RemoteViewsService.RemoteViewsFactory { } remoteViews.setImageViewBitmap(R.id.photo, null) - contact?.numbers?.firstOrNull()?.address?.let { address -> - val futureGet = GlideApp.with(context) - .asBitmap() - .load("tel:$address") - .submit(48.dpToPx(context), 48.dpToPx(context)) - - try { - remoteViews.setImageViewBitmap(R.id.photo, futureGet.get()) - } catch (e: Exception) { - } - } + val futureGet = GlideApp.with(context) + .asBitmap() + .load(contact?.photoUri) + .submit(48.dpToPx(context), 48.dpToPx(context)) + tryOrNull(false) { remoteViews.setImageViewBitmap(R.id.photo, futureGet.get()) } // Name remoteViews.setTextColor(R.id.name, textPrimary) remoteViews.setTextViewText(R.id.name, boldText(conversation.getTitle(), conversation.unread)) // Date - val timestamp = dateFormatter.getConversationTimestamp(conversation.date) + val timestamp = conversation.date.takeIf { it > 0 }?.let(dateFormatter::getConversationTimestamp) remoteViews.setTextColor(R.id.date, if (conversation.unread) textPrimary else textTertiary) remoteViews.setTextViewText(R.id.date, boldText(timestamp, conversation.unread)) // Snippet + val snippet = when { + conversation.draft.isNotEmpty() -> context.getString(R.string.main_draft, conversation.draft) + conversation.me -> context.getString(R.string.main_sender_you, conversation.snippet) + else -> conversation.snippet + } remoteViews.setTextColor(R.id.snippet, if (conversation.unread) textPrimary else textTertiary) - remoteViews.setTextViewText(R.id.snippet, boldText(conversation.snippet, conversation.unread)) + remoteViews.setTextViewText(R.id.snippet, boldText(snippet, conversation.unread)) // Launch conversation on click val clickIntent = Intent().putExtra("threadId", conversation.id) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetProvider.kt b/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetProvider.kt index 3c3960d8616ded9069cacb48d41359d89903582d..0bb09316c473219e6c64790ab6a3ee94ea7896fe 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetProvider.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/widget/WidgetProvider.kt @@ -120,8 +120,8 @@ class WidgetProvider : AppWidgetProvider() { remoteViews.setInt(R.id.toolbar, "setColorFilter", context.getColorCompat(when { night && black -> R.color.black - night && !black -> R.color.toolbarDark - else -> R.color.toolbarLight + night && !black -> R.color.backgroundDark + else -> R.color.backgroundLight })) remoteViews.setTextColor(R.id.title, context.getColorCompat(when (night) { diff --git a/presentation/src/main/java/com/moez/QKSMS/injection/AppComponent.kt b/presentation/src/main/java/com/moez/QKSMS/injection/AppComponent.kt index 0ac8b34b2e975a62c90618e9198afa1d444ee6db..d475bdde85bfee8df8457250736fd09a933eade8 100644 --- a/presentation/src/main/java/com/moez/QKSMS/injection/AppComponent.kt +++ b/presentation/src/main/java/com/moez/QKSMS/injection/AppComponent.kt @@ -33,7 +33,7 @@ import com.moez.QKSMS.feature.blocking.BlockingController import com.moez.QKSMS.feature.blocking.manager.BlockingManagerController import com.moez.QKSMS.feature.blocking.messages.BlockedMessagesController import com.moez.QKSMS.feature.blocking.numbers.BlockedNumbersController -import com.moez.QKSMS.feature.compose.DetailedChipView +import com.moez.QKSMS.feature.compose.editing.DetailedChipView import com.moez.QKSMS.feature.conversationinfo.injection.ConversationInfoComponent import com.moez.QKSMS.feature.service.ESmsRestoreService import com.moez.QKSMS.feature.settings.SettingsController @@ -44,7 +44,6 @@ import com.moez.QKSMS.feature.widget.WidgetAdapter import com.moez.QKSMS.injection.android.ActivityBuilderModule import com.moez.QKSMS.injection.android.BroadcastReceiverBuilderModule import com.moez.QKSMS.injection.android.ServiceBuilderModule -import com.moez.QKSMS.util.ContactImageLoader import dagger.Component import dagger.android.support.AndroidSupportInjectionModule import javax.inject.Singleton @@ -74,8 +73,6 @@ interface AppComponent { fun inject(dialog: QkDialog) - fun inject(fetcher: ContactImageLoader.ContactImageFetcher) - fun inject(service: WidgetAdapter) /** @@ -94,4 +91,4 @@ interface AppComponent { fun inject(view: QkSwitch) fun inject(view: QkTextView) -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/moez/QKSMS/injection/AppModule.kt b/presentation/src/main/java/com/moez/QKSMS/injection/AppModule.kt index b8f68adbb7fb627bd76057b017fff4578821c45e..f0ffcca7ecd5ec415bfe98d93cce180236b89def 100644 --- a/presentation/src/main/java/com/moez/QKSMS/injection/AppModule.kt +++ b/presentation/src/main/java/com/moez/QKSMS/injection/AppModule.kt @@ -21,6 +21,7 @@ package com.moez.QKSMS.injection import android.app.Application import android.content.ContentResolver import android.content.Context +import android.content.SharedPreferences import android.preference.PreferenceManager import androidx.lifecycle.ViewModelProvider import com.f2prateek.rx.preferences2.RxSharedPreferences @@ -46,10 +47,16 @@ import com.moez.QKSMS.manager.KeyManagerImpl import com.moez.QKSMS.manager.NotificationManager import com.moez.QKSMS.manager.PermissionManager import com.moez.QKSMS.manager.PermissionManagerImpl +import com.moez.QKSMS.manager.ReferralManager +import com.moez.QKSMS.manager.ReferralManagerImpl import com.moez.QKSMS.manager.ShortcutManager import com.moez.QKSMS.manager.WidgetManager import com.moez.QKSMS.manager.WidgetManagerImpl import com.moez.QKSMS.mapper.CursorToContact +import com.moez.QKSMS.mapper.CursorToContactGroup +import com.moez.QKSMS.mapper.CursorToContactGroupImpl +import com.moez.QKSMS.mapper.CursorToContactGroupMember +import com.moez.QKSMS.mapper.CursorToContactGroupMemberImpl import com.moez.QKSMS.mapper.CursorToContactImpl import com.moez.QKSMS.mapper.CursorToConversation import com.moez.QKSMS.mapper.CursorToConversationImpl @@ -67,8 +74,6 @@ import com.moez.QKSMS.repository.ContactRepository import com.moez.QKSMS.repository.ContactRepositoryImpl import com.moez.QKSMS.repository.ConversationRepository import com.moez.QKSMS.repository.ConversationRepositoryImpl -import com.moez.QKSMS.repository.ImageRepository -import com.moez.QKSMS.repository.ImageRepositoryImpl import com.moez.QKSMS.repository.MessageRepository import com.moez.QKSMS.repository.MessageRepositoryImpl import com.moez.QKSMS.repository.ScheduledMessageRepository @@ -95,8 +100,13 @@ class AppModule(private var application: Application) { @Provides @Singleton - fun provideRxPreferences(context: Context): RxSharedPreferences { - val preferences = PreferenceManager.getDefaultSharedPreferences(context) + fun provideSharedPreferences(context: Context): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(context) + } + + @Provides + @Singleton + fun provideRxPreferences(preferences: SharedPreferences): RxSharedPreferences { return RxSharedPreferences.create(preferences) } @@ -145,6 +155,9 @@ class AppModule(private var application: Application) { @Provides fun provideShortcutManager(manager: ShortcutManagerImpl): ShortcutManager = manager + @Provides + fun provideReferralManager(manager: ReferralManagerImpl): ReferralManager = manager + @Provides fun provideWidgetManager(manager: WidgetManagerImpl): WidgetManager = manager @@ -153,6 +166,12 @@ class AppModule(private var application: Application) { @Provides fun provideCursorToContact(mapper: CursorToContactImpl): CursorToContact = mapper + @Provides + fun provideCursorToContactGroup(mapper: CursorToContactGroupImpl): CursorToContactGroup = mapper + + @Provides + fun provideCursorToContactGroupMember(mapper: CursorToContactGroupMemberImpl): CursorToContactGroupMember = mapper + @Provides fun provideCursorToConversation(mapper: CursorToConversationImpl): CursorToConversation = mapper @@ -179,9 +198,6 @@ class AppModule(private var application: Application) { @Provides fun provideConversationRepository(repository: ConversationRepositoryImpl): ConversationRepository = repository - @Provides - fun provideImageRepository(repository: ImageRepositoryImpl): ImageRepository = repository - @Provides fun provideMessageRepository(repository: MessageRepositoryImpl): MessageRepository = repository diff --git a/presentation/src/main/java/com/moez/QKSMS/injection/android/ActivityBuilderModule.kt b/presentation/src/main/java/com/moez/QKSMS/injection/android/ActivityBuilderModule.kt index eb38d2f3a49067e537b0bf82eb2873ee88db0a49..416d9009ce837c1c9764eabe775d667402214add 100644 --- a/presentation/src/main/java/com/moez/QKSMS/injection/android/ActivityBuilderModule.kt +++ b/presentation/src/main/java/com/moez/QKSMS/injection/android/ActivityBuilderModule.kt @@ -22,6 +22,8 @@ import com.moez.QKSMS.feature.backup.BackupActivity import com.moez.QKSMS.feature.blocking.BlockingActivity import com.moez.QKSMS.feature.compose.ComposeActivity import com.moez.QKSMS.feature.compose.ComposeActivityModule +import com.moez.QKSMS.feature.contacts.ContactsActivity +import com.moez.QKSMS.feature.contacts.ContactsActivityModule import com.moez.QKSMS.feature.conversationinfo.ConversationInfoActivity import com.moez.QKSMS.feature.gallery.GalleryActivity import com.moez.QKSMS.feature.gallery.GalleryActivityModule @@ -55,6 +57,10 @@ abstract class ActivityBuilderModule { @ContributesAndroidInjector(modules = [ComposeActivityModule::class]) abstract fun bindComposeActivity(): ComposeActivity + @ActivityScope + @ContributesAndroidInjector(modules = [ContactsActivityModule::class]) + abstract fun bindContactsActivity(): ContactsActivity + @ActivityScope @ContributesAndroidInjector(modules = []) abstract fun bindConversationInfoActivity(): ConversationInfoActivity diff --git a/presentation/src/main/res/drawable/circle.xml b/presentation/src/main/res/drawable/circle.xml index eeac2e096a72f1db0ad4ebc2e77cf9cbbda29792..a073500beab39fc3eb6bbd4eecd03d5c1abaffa3 100644 --- a/presentation/src/main/res/drawable/circle.xml +++ b/presentation/src/main/res/drawable/circle.xml @@ -18,8 +18,9 @@ ~ along with QKSMS. If not, see . --> + android:shape="rectangle"> + - \ No newline at end of file + diff --git a/presentation/src/main/res/drawable/color_picker_gradient.xml b/presentation/src/main/res/drawable/color_picker_gradient.xml index 74e5855c8e7743e315ac988165c6a105ed52a14d..50f0d5cef5b2295075f4e82cf0ef06006b266649 100644 --- a/presentation/src/main/res/drawable/color_picker_gradient.xml +++ b/presentation/src/main/res/drawable/color_picker_gradient.xml @@ -23,7 +23,8 @@ - \ No newline at end of file + diff --git a/presentation/src/main/res/drawable/ic_message_black_24dp.xml b/presentation/src/main/res/drawable/ic_message_black_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..d2876bfad9b41c533cbcab6156b7585e326a5644 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_message_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/presentation/src/main/res/drawable/rounded_rectangle_22dp.xml b/presentation/src/main/res/drawable/rounded_rectangle_22dp.xml index d1754416e8798bb408ba68ede412626dbcab7625..7a6af7bb21b1857c25c917cfd3891ad61d03deb9 100755 --- a/presentation/src/main/res/drawable/rounded_rectangle_22dp.xml +++ b/presentation/src/main/res/drawable/rounded_rectangle_22dp.xml @@ -24,4 +24,4 @@ - \ No newline at end of file + diff --git a/presentation/src/main/res/layout/avatar_view.xml b/presentation/src/main/res/layout/avatar_view.xml index c0896e8f6925cebed153e8f3f3714f5464a4041e..420fb79607b340001b302b26d738bb503c560a4b 100644 --- a/presentation/src/main/res/layout/avatar_view.xml +++ b/presentation/src/main/res/layout/avatar_view.xml @@ -18,11 +18,11 @@ ~ along with QKSMS. If not, see . --> + app:autoSizeMaxTextSize="22sp" + app:autoSizeTextType="uniform" /> - \ No newline at end of file + diff --git a/presentation/src/main/res/layout/blocked_list_item.xml b/presentation/src/main/res/layout/blocked_list_item.xml index 5c41be73ce0b6ca64cbd956f555301c9ac4d32bf..a70b945eb90c9a371ee949c098d37f290b881eee 100644 --- a/presentation/src/main/res/layout/blocked_list_item.xml +++ b/presentation/src/main/res/layout/blocked_list_item.xml @@ -23,15 +23,15 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?attr/selectableItemBackground" - android:paddingTop="8dp" - android:paddingBottom="8dp" + android:paddingTop="4dp" + android:paddingBottom="4dp" app:layout_constraintVertical_chainStyle="packed"> @@ -69,12 +70,11 @@ android:id="@+id/blocker" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="16dp" android:layout_marginTop="2dp" android:textColor="?android:attr/textColorTertiary" android:textStyle="bold" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintStart_toEndOf="@id/avatars" + app:layout_constraintStart_toStartOf="@id/title" app:layout_constraintTop_toBottomOf="@id/title" app:textSize="secondary" tools:text="Call Control" /> diff --git a/presentation/src/main/res/layout/compose_activity.xml b/presentation/src/main/res/layout/compose_activity.xml index bc3f373154980266b725f79529c049392c70e55a..6e804dd6e1775d2e6765e4d59924403898aa51d9 100644 --- a/presentation/src/main/res/layout/compose_activity.xml +++ b/presentation/src/main/res/layout/compose_activity.xml @@ -23,7 +23,7 @@ android:id="@+id/contentView" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="?attr/composeBackground" + android:background="?android:attr/windowBackground" android:orientation="vertical" tools:context="com.moez.QKSMS.feature.compose.ComposeActivity"> @@ -31,15 +31,15 @@ android:id="@+id/messageList" android:layout_width="0dp" android:layout_height="0dp" + android:layout_marginBottom="12dp" android:clipChildren="false" android:clipToPadding="false" android:paddingTop="8dp" - android:paddingBottom="16dp" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - app:layout_constraintBottom_toBottomOf="@id/composeBackground" + app:layout_constraintBottom_toTopOf="@id/messageBackground" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/toolbar" + app:layout_constraintTop_toBottomOf="@id/sendAsGroupBackground" app:stackFromEnd="true" tools:listitem="@layout/message_list_item_in" /> @@ -48,13 +48,13 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="56dp" - android:layout_marginTop="16dp" + android:layout_marginTop="32dp" android:layout_marginEnd="56dp" android:gravity="center" android:text="@string/compose_messages_empty" android:textColor="?android:attr/textColorTertiary" android:visibility="gone" - app:layout_constraintTop_toBottomOf="@id/toolbar" + app:layout_constraintTop_toBottomOf="@id/sendAsGroupBackground" app:textSize="secondary" /> - - + app:constraint_referenced_ids="messageBackground,attachments,attach,message,counter,send" /> + android:id="@+id/composeDivider" + android:layout_width="match_parent" + android:layout_height="1dp" + android:layout_marginBottom="12dp" + android:background="?android:attr/divider" + android:visibility="gone" + app:layout_constraintBottom_toTopOf="@id/messageBackground" + app:layout_goneMarginBottom="12dp" /> @@ -185,13 +166,12 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="gone" - app:constraint_referenced_ids="scheduledTitle, scheduledTime, scheduledCancel, scheduledSeparator" /> + app:constraint_referenced_ids="scheduledTitle,scheduledTime,scheduledCancel,scheduledSeparator" /> @@ -246,7 +223,6 @@ android:layout_height="wrap_content" android:clipChildren="false" android:clipToPadding="false" - android:elevation="4dp" android:orientation="horizontal" android:paddingStart="8dp" android:paddingEnd="8dp" @@ -263,7 +239,6 @@ android:layout_height="wrap_content" android:layout_weight="1" android:background="@null" - android:elevation="4dp" android:gravity="center_vertical" android:hint="@string/compose_hint" android:inputType="textLongMessage|textCapSentences|textMultiLine" @@ -280,12 +255,11 @@ + app:layout_constraintEnd_toEndOf="parent" /> + app:constraint_referenced_ids="contact,contactLabel,schedule,scheduleLabel,gallery,galleryLabel,camera,cameraLabel,attachingBackground" /> diff --git a/presentation/src/main/res/layout/contact_chip.xml b/presentation/src/main/res/layout/contact_chip.xml index 76805b05dc31d870f6f7722c8d89a3f02e878307..6627a443421761b3542e6a0a3ea2bd0a59120c35 100755 --- a/presentation/src/main/res/layout/contact_chip.xml +++ b/presentation/src/main/res/layout/contact_chip.xml @@ -30,7 +30,7 @@ android:layout_height="36dp" android:layout_margin="4dp" android:background="@drawable/chip_background" - android:backgroundTint="?attr/composeBackground" + android:backgroundTint="?attr/bubbleColor" android:orientation="horizontal"> + android:layout_height="wrap_content" + android:background="?attr/selectableItemBackground"> - + + + + - - - - + app:layout_constraintStart_toStartOf="@id/title" + app:layout_constraintTop_toBottomOf="@id/title" + app:textSize="secondary" + tools:text="Moez Bhatti" /> \ No newline at end of file diff --git a/presentation/src/main/res/layout/contact_number_list_item.xml b/presentation/src/main/res/layout/contact_number_list_item.xml index 5aa2b23d971b0a94111dc0f7bfebd627a77b7d5b..0ea6fab36992f3e246c783d420261f57fedee6e1 100644 --- a/presentation/src/main/res/layout/contact_number_list_item.xml +++ b/presentation/src/main/res/layout/contact_number_list_item.xml @@ -22,26 +22,27 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:background="?attr/selectableItemBackground"> + android:paddingTop="2dp"> + tools:text="Mobile" /> + android:textColor="?android:attr/textColorTertiary" + app:textSize="secondary" + tools:text="(123) 456-7890" /> \ No newline at end of file diff --git a/presentation/src/main/res/layout/contacts_activity.xml b/presentation/src/main/res/layout/contacts_activity.xml new file mode 100644 index 0000000000000000000000000000000000000000..89181ebff59a7f8ec021336a12c55ceb7e9b1684 --- /dev/null +++ b/presentation/src/main/res/layout/contacts_activity.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/layout/conversation_info_controller.xml b/presentation/src/main/res/layout/conversation_info_controller.xml index 40670ba9446fca987763f4a1472c07fb92473185..65558d5d78d1a3563f7e8a4bf41447fe148837c1 100644 --- a/presentation/src/main/res/layout/conversation_info_controller.xml +++ b/presentation/src/main/res/layout/conversation_info_controller.xml @@ -17,98 +17,11 @@ ~ You should have received a copy of the GNU General Public License ~ along with QKSMS. If not, see . --> - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + android:clipChildren="false" + android:clipToPadding="false" + android:paddingTop="8dp" + android:paddingBottom="8dp" /> diff --git a/presentation/src/main/res/layout/conversation_info_settings.xml b/presentation/src/main/res/layout/conversation_info_settings.xml new file mode 100644 index 0000000000000000000000000000000000000000..50d5903af13c5cc8b0555ee6be347665f3420d73 --- /dev/null +++ b/presentation/src/main/res/layout/conversation_info_settings.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/layout/conversation_list_item.xml b/presentation/src/main/res/layout/conversation_list_item.xml index e4159c53e58f841fb05bb89a9956163139839d63..1ed7eebfc07e761286c86625d7602fb64c84c5e6 100644 --- a/presentation/src/main/res/layout/conversation_list_item.xml +++ b/presentation/src/main/res/layout/conversation_list_item.xml @@ -25,15 +25,15 @@ android:background="?attr/selectableItemBackground" android:gravity="center_vertical" android:orientation="horizontal" - android:paddingStart="16dp" - android:paddingTop="12dp" + android:paddingStart="12dp" + android:paddingTop="8dp" android:paddingEnd="16dp" - android:paddingBottom="12dp"> + android:paddingBottom="8dp"> - diff --git a/presentation/src/main/res/layout/conversation_recipient_list_item.xml b/presentation/src/main/res/layout/conversation_recipient_list_item.xml index 959413c4e223330f48b8527acc3174e3fe29d051..970d478ec5b73b7e04dcf4a1945c878853d68285 100644 --- a/presentation/src/main/res/layout/conversation_recipient_list_item.xml +++ b/presentation/src/main/res/layout/conversation_recipient_list_item.xml @@ -42,7 +42,10 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="16dp" + android:layout_marginEnd="8dp" app:layout_constraintBottom_toTopOf="@id/address" + app:layout_constraintEnd_toStartOf="@id/add" + app:layout_constraintHorizontal_bias="0" app:layout_constraintStart_toEndOf="@id/avatar" app:layout_constraintTop_toTopOf="parent" app:layout_constraintVertical_chainStyle="packed" @@ -54,6 +57,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="@id/name" app:layout_constraintStart_toStartOf="@id/name" app:layout_constraintTop_toBottomOf="@id/name" tools:text="(123) 456-7890" /> @@ -62,11 +66,24 @@ android:id="@+id/add" android:layout_width="40dp" android:layout_height="40dp" + android:layout_marginEnd="16dp" android:padding="8dp" android:src="@drawable/ic_person_add_black_24dp" android:tint="?android:attr/textColorSecondary" app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/theme" + app:layout_constraintTop_toTopOf="parent" /> + + - \ No newline at end of file + diff --git a/presentation/src/main/res/layout/field_dialog.xml b/presentation/src/main/res/layout/field_dialog.xml index 5170aeb3fed99aa2c52550b26b13bd7067de51a6..b0fec2b93df43292a31cb241f2bff94c09a701fa 100644 --- a/presentation/src/main/res/layout/field_dialog.xml +++ b/presentation/src/main/res/layout/field_dialog.xml @@ -25,7 +25,7 @@ android:layout_height="wrap_content" android:background="@null" android:lines="1" - android:maxLength="30" + android:maxLength="64" android:padding="24dp" android:singleLine="true" android:textStyle="bold" diff --git a/presentation/src/main/res/layout/group_avatar_view.xml b/presentation/src/main/res/layout/group_avatar_view.xml index c752aec43553069d376fe44d5be755e432ff3dff..52a524267a9b84d064b9cb268809583bcfdfcbd2 100644 --- a/presentation/src/main/res/layout/group_avatar_view.xml +++ b/presentation/src/main/res/layout/group_avatar_view.xml @@ -22,34 +22,50 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="horizontal" tools:parentTag="com.moez.QKSMS.common.widget.GroupAvatarView"> - + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintVertical_bias="0" + app:layout_constraintWidth_percent=".75"> - + - + + + app:layout_constraintHorizontal_bias="0" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintVertical_bias="1" + app:layout_constraintWidth_percent=".75"> + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/hsv_picker_view.xml b/presentation/src/main/res/layout/hsv_picker_view.xml index 1ae8c543d1700cadc8f04530c1e3642aa1cb75f8..bdc1685a7b8aa86f74d473e53faef91df24ef348 100644 --- a/presentation/src/main/res/layout/hsv_picker_view.xml +++ b/presentation/src/main/res/layout/hsv_picker_view.xml @@ -29,7 +29,6 @@ android:layout_height="0dp" android:background="@drawable/rounded_rectangle_4dp" android:backgroundTint="@color/white" - android:rotation="90" app:layout_constraintBottom_toBottomOf="@id/saturation" app:layout_constraintEnd_toEndOf="@id/saturation" app:layout_constraintStart_toStartOf="@id/saturation" diff --git a/presentation/src/main/res/layout/main_activity.xml b/presentation/src/main/res/layout/main_activity.xml index 0e52fde041ce3092e078bf9035f9c3fcea554797..f19d171f738c0439426629c3688ece538163d931 100644 --- a/presentation/src/main/res/layout/main_activity.xml +++ b/presentation/src/main/res/layout/main_activity.xml @@ -53,7 +53,7 @@ android:layout_marginEnd="8dp" android:layout_marginBottom="8dp" android:background="@drawable/rounded_rectangle_24dp" - android:backgroundTint="?android:attr/queryBackground" + android:backgroundTint="?attr/bubbleColor" android:hint="@string/title_conversations" android:paddingStart="16dp" android:paddingEnd="16dp" @@ -84,6 +84,7 @@ android:clipToPadding="false" android:paddingTop="8dp" android:paddingBottom="8dp" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layout_constraintBottom_toTopOf="@id/bottom" app:layout_constraintTop_toBottomOf="@id/toolbar" tools:listitem="@layout/conversation_list_item" /> diff --git a/presentation/src/main/res/layout/message_list_item_in.xml b/presentation/src/main/res/layout/message_list_item_in.xml index 2201260d82af6ac38b44c219debad113c40d75ba..0c5d7f90a57f2d654582342c0697e3913e700c03 100644 --- a/presentation/src/main/res/layout/message_list_item_in.xml +++ b/presentation/src/main/res/layout/message_list_item_in.xml @@ -103,8 +103,9 @@ app:layout_constraintHorizontal_bias="0" app:layout_constraintStart_toEndOf="@id/avatar" app:layout_constraintTop_toBottomOf="@id/attachments" + app:layout_constraintWidth_max="384dp" tools:backgroundTint="@color/tools_theme" - tools:text="@tools:sample/lorem" /> + tools:text="@tools:sample/lorem/random" /> + + . --> - + - + - \ No newline at end of file + diff --git a/presentation/src/main/res/layout/qk_dialog.xml b/presentation/src/main/res/layout/qk_dialog.xml new file mode 100644 index 0000000000000000000000000000000000000000..f38decc29c129eacd6b43ecfed9f8908f6fd4227 --- /dev/null +++ b/presentation/src/main/res/layout/qk_dialog.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/layout/qkreply_activity.xml b/presentation/src/main/res/layout/qkreply_activity.xml index 5939a51a5601e232266c1cac01fd0d4ee0e87b5b..f7b51a277bac0350521d5d648d077e982e84c290 100644 --- a/presentation/src/main/res/layout/qkreply_activity.xml +++ b/presentation/src/main/res/layout/qkreply_activity.xml @@ -26,9 +26,9 @@ android:layout_gravity="center" android:layout_margin="24dp" android:background="@drawable/rounded_rectangle_4dp" - android:backgroundTint="?attr/composeBackground" + android:backgroundTint="?android:attr/windowBackground" android:elevation="8dp" - tools:context="com.moez.QKSMS.feature.qkreply.QkReplyActivity"> + tools:context=".feature.qkreply.QkReplyActivity"> @@ -83,10 +81,9 @@ style="@style/TextPrimary" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginBottom="8dp" + android:layout_marginStart="12dp" + android:layout_marginBottom="12dp" android:background="@null" - android:elevation="4dp" android:gravity="center_vertical" android:hint="@string/compose_hint" android:inputType="textLongMessage|textCapSentences|textMultiLine" @@ -107,18 +104,16 @@ android:layout_height="44dp" android:background="?attr/selectableItemBackground" android:contentDescription="@string/compose_sim_cd" - android:elevation="4dp" android:padding="10dp" android:src="@drawable/ic_sim_card_black_24dp" android:tint="?android:attr/textColorSecondary" app:layout_constraintBottom_toBottomOf="@id/message" - app:layout_constraintEnd_toStartOf="@id/send" /> + app:layout_constraintEnd_toEndOf="@id/messageBackground" /> + android:padding="12dp"> + + + + + + - \ No newline at end of file + + + diff --git a/presentation/src/main/res/values-ar/strings.xml b/presentation/src/main/res/values-ar/strings.xml index 8b54784d034a00ac4574c6a96a43293ce16ad1ee..efa7c8c43b746727dd3c83e5944fa858af1018d5 100644 --- a/presentation/src/main/res/values-ar/strings.xml +++ b/presentation/src/main/res/values-ar/strings.xml @@ -30,16 +30,18 @@ أكتب اسماً أو رقماً تخطي متابعة + اضافة شخص اتصال تفاصيل حفظ إلى المعرض + مشاركة فتح درج التنقل %d محددة مسح أرشفة إلغاء الأرشفة حذف - Add to contacts + اضافة الى القائمة التثبيت فوق إلغاء التثبيت علّمه مقروء @@ -83,6 +85,10 @@ إعادة توجيه حذف + اختر رقم الهاتف + %s ∙ Default + فقط واحد + دائما %d مختارة %1$d من %2$d النتائج إرسال رسالة جماعية @@ -122,6 +128,7 @@ تم إرسال %s فشل الإرسال. إلمس لإعادة المحاولة التفاصيل + تم نسخ العنوان عنوان المحادثة الإشعارات السمة @@ -175,8 +182,8 @@ الرسالة المجدولة أرسل الآن - Copy text - Delete + نسخ النص + حذف المظهر عام @@ -186,6 +193,7 @@ وضع ليلي بسوادٍ حالك وقت البدء وقت الانتهاء + Automatic contact colors حجم الخط استخدم خط النظام إيموجي تلقائية @@ -196,6 +204,7 @@ الزر 2 الزر 3 معاينات الإشعارات + فتح الشاشة الاهتزاز الصوت بدون نغمة @@ -225,6 +234,8 @@ حذف حركات التشكيل من المحارف في الرسائل النصية الصادرة أرقام الهواتف النقالة فقط عند إنشاء رسالة، أظهر فقط أرقام الهواتف النقالة + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply الضغط التلقائي لمرفقات رسائل الوسائط مزامنة الرسائل إعادة مزامنة رسائلك مع قاعدة البيانات الأصلية للرسائل في النظام @@ -373,13 +384,14 @@ طويل - ١٠٠ ك.ب. - ٢٠٠ ك.ب. - ٣٠٠ ك.ب. (مستحسن) - ٦٠٠ ك.ب. - ١٠٠٠ ك.ب. - ٢٠٠٠ ك.ب. - دون ضغط + تلقائي + 100كيلوبايت + 200كيلوبايت + 300كيلوبايت + 600كيلوبايت + 1000كيلوبايت + 2000كيلوبايت + No compression حسنًا diff --git a/presentation/src/main/res/values-bn/strings.xml b/presentation/src/main/res/values-bn/strings.xml index 0eb38018d2287b45e04ba25fea555cb305cf983d..5a15bf69b97227e38e58d1eb011741876c45ff57 100644 --- a/presentation/src/main/res/values-bn/strings.xml +++ b/presentation/src/main/res/values-bn/strings.xml @@ -30,9 +30,11 @@ নাম বা নম্বর টাইপ করুন এড়িয়ে যান চালিয়ে যান + Add person কল করুন বিস্তারিত গ্যালারীতে সেভ করুন + Share নেভিগেশন ড্রয়ার খুলুন নির্বাচিত %d ক্লিয়ার @@ -83,6 +85,10 @@ সামনে পাঠান মুছে ফেলুন + Choose a phone number + %s ∙ Default + Just once + Always নির্বাচিত %d %2$d এর %1$d ফলাফল গ্রুপ বার্তা হিসেবে প্রেরণ করুন @@ -122,6 +128,7 @@ প্রেরিত হয়েছে: %s পাঠাতে ব্যর্থ. আবার চেষ্টা করতে ট্যাপ করুন বিস্তারিত + Address copied কথোপকথনের শিরোনাম নোটিফিকেশন থিম @@ -183,6 +190,7 @@ সম্পূর্ণ কালো রাত্রি মোড শুরুর সময় শেষের সময় + Automatic contact colors ফন্টের আকার সিস্টেম ফন্ট ব্যবহার করুন স্বয়ংক্রিয় ইমোজি @@ -193,6 +201,7 @@ বাটন ২ বাটন ৩ নোটিফিকেশন প্রিভিউ + Wake screen কম্পন শব্দ কোনোটা না @@ -222,6 +231,8 @@ বহির্গামী এসএমএস বার্তার অক্ষর থেকে কথার টান সরিয়ে দিন শুধু মোবাইল নম্বর বার্তা লিখার করার সময়, শুধুমাত্র মোবাইল নম্বর দেখান + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply এমএমএসের সংযুক্তি সক্রিয়ভাবে সংকোচন করুন বার্তাগুলি সিঙ্ক করুন আপনার বার্তাগুলি অ্যান্ড্রয়েডের নিজস্ব এসএমএস এর ডাটাবেস সাতে পুনরায় সিঙ্ক করুন @@ -357,13 +368,14 @@ দীর্ঘ - ১০০KB - ২০০KB - ৩০০KB (সুপারিশ করা) - ৬০০KB - ১০০০KB - ২০০০KB - সংকোচনহীন + Automatic + 100KB + 200KB + 300KB + 600KB + 1000KB + 2000KB + No compression ঠিক আছে diff --git a/presentation/src/main/res/values-cs/strings.xml b/presentation/src/main/res/values-cs/strings.xml index 674b07e0e6bf909d91e7db03748eee5172009869..69cae7a2d9a3acd33d2e0c21fe556cbed5923a8c 100644 --- a/presentation/src/main/res/values-cs/strings.xml +++ b/presentation/src/main/res/values-cs/strings.xml @@ -30,9 +30,11 @@ Nová zpráva Přeskočit Pokračovat + Přidat osobu Volat Podrobnosti Uložit do galerie + Sdílet Otevřít menu Vybráno: %d Vymazat @@ -85,6 +87,10 @@ Přeposlat Smazat + Vybrat telefonní číslo + %s ∙ Výchozí + Pouze jednou + Vždy Vybráno: %d %1$d z %2$d výsledků Odeslat jako skupinovou zprávu @@ -124,6 +130,7 @@ Doručeno %s Odeslání se nezdařilo. Klepnutím zkusíte znovu odeslat Podrobnosti + Adresa zkopírována Název konverzace Oznámení Motiv vzhledu @@ -186,6 +193,7 @@ Čistá černá, noční režim Čas zahájení Čas ukončení + Automatické barvy kontaktu Velikost písma Použít systémové písmo Automatické emoji @@ -196,6 +204,7 @@ Tlačítko 2 Tlačítko 3 Nastavení oznámení + Rozsvítit displej Vibrace Vyzvánění Žádný @@ -225,6 +234,8 @@ Odstranit diakritiku v odesílaných SMS zprávách Pouze mobilní čísla Při psaní zprávy zobrazit pouze mobilní telefonní čísla + Odesílat dlouhé zprávy jako MMS + Nedaří-li se odeslat vaše dlouhé zprávy, nebo jsou-li poslány v nesprávném pořadí, lze je odeslat jako MMS. Mohou být účtovány další poplatky. Automaticky komprimovat MMS přílohy Synchronizovat zprávy Opětovná synchronizace zpráv s nativní Android SMS databází @@ -366,13 +377,14 @@ Dlouhá + Automaticky 100 kB 200 kB - 300 kB (doporučeno) + 300 kB 600 kB 1 000 kB 2 000 kB - Bez komprese + Bez komprese OK diff --git a/presentation/src/main/res/values-da/strings.xml b/presentation/src/main/res/values-da/strings.xml index d4bb129b1b688c43929cf773f14d8b0b32d8407c..880643da62f029c2ee214cf13be9fd4c17cb9f89 100644 --- a/presentation/src/main/res/values-da/strings.xml +++ b/presentation/src/main/res/values-da/strings.xml @@ -30,9 +30,11 @@ Angiv et navn eller nummer Overspring Fortsæt + Tilføj person Opkald Oplysninger Gem i Galleri + Del Åbn navigeringsskuffe %d valgt Ryd @@ -79,6 +81,10 @@ Videresend Slet + Vælg et telefonnr. + %s ∙ Standard + Kun én gang + Altid %d valgt %1$d af %2$d resultater Send som gruppe besked @@ -118,6 +124,7 @@ Leveret %s Mislykkedes at afsende. Tryk for at forsøge igen Oplysninger + Adresse kopieret Samtaletitel Notifikationer Tema @@ -178,6 +185,7 @@ Ren sort nattilstand Starttidspunkt Sluttidspunkt + Automatiske kontaktfarver Skriftstørrelse Brug systemets skrifttype Automatisk emoji @@ -188,6 +196,7 @@ Knap 2 Knap 3 Notifikationseksempler + Væk skærm Vibration Lyd Ingen @@ -217,6 +226,8 @@ Fjern betoninger fra tegn i udgående SMS\'er Kun mobilnumre Vis kun mobilnumre ved SMS-skrivning, + Send lang SMS som MMS + Hvis dine længere SMS\'er ikke afsendes eller afsendes i forkert rækkefølge, kan du i stedet sende dem som MMS\'er. Ekstragebyrer kan påløbe Auto-komprimér MMS-vedhæftninger Beskedsynkronisering Gen-synkroniéer beskeder med den indbyggede Android SMS-database @@ -352,13 +363,14 @@ Lang - 100 KB - 200 KB - 300 KB (anbefales) - 600 KB - 1.000 KB - 2.000 KB - Ingen komprimering + Automatisk + 100KB + 200KB + 300KB + 600KB + 1MB + 2MB + Ingen kompression OK diff --git a/presentation/src/main/res/values-de/strings.xml b/presentation/src/main/res/values-de/strings.xml index b296922a2a6aed0b0ab40a9bb24d2ad88957e9bd..9c07f6930ec146529e4b3326ea9c4a86f7fbdb97 100644 --- a/presentation/src/main/res/values-de/strings.xml +++ b/presentation/src/main/res/values-de/strings.xml @@ -30,9 +30,11 @@ Verfassen Überspringen Weiter + Person hinzufügen Anrufen Details In Galerie speichern + Teilen Öffne den Navigation Drawer %d ausgewählt Zurücksetzen @@ -83,6 +85,10 @@ Weiterleiten Löschen + Wählen Sie eine Telefonnummer + %s - Standard + Nur einmal + Immer %d ausgewählt %1$d von %2$d Ergebnissen Als Gruppennachricht senden @@ -122,6 +128,7 @@ Zugestellt %s Fehler beim Senden. Tippen, um es erneut zu versuchen Details + Adresse kopiert Unterhaltungstitel Benachrichtigungen Erscheinungsbild @@ -182,6 +189,7 @@ Rein schwarzer Nachtmodus Anfang Ende + Automatische Kontaktfarben Schriftgröße Systemschriftart verwenden Automatische Emoji @@ -192,6 +200,7 @@ Taste 2 Taste 3 Benachrichtigungsvorschau + Bildschirm aufwecken Vibration Ton Keine @@ -221,6 +230,8 @@ Akzente aus Zeichen in ausgehenden SMS-Nachrichten entfernen Nur Mobilfunknummern Beim Verfassen einer Nachricht nur Mobilfunknummern anzeigen + Lange Nachrichten als MMS senden + Wenn Ihre längeren Textnachrichten nicht gesendet oder in der falsche Reihenfolge gesendet werden, können Sie sie stattdessen als MMS-Nachrichten senden. Dafür können zusätzliche Gebühren anfallen. MMS-Anhänge automatisch komprimieren Nachrichten synchronisieren Nachrichten mit der Android SMS-Datenbank abgleichen @@ -356,9 +367,10 @@ Lang + Automatisch 100KB 200KB - 300KB (empfohlen) + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-el/strings.xml b/presentation/src/main/res/values-el/strings.xml index 8b9ddea47bf5a54354a3e360037e496d56906bed..0afab790c137ee6a0e24825f8c6864e10e8e93e3 100644 --- a/presentation/src/main/res/values-el/strings.xml +++ b/presentation/src/main/res/values-el/strings.xml @@ -30,9 +30,11 @@ Πληκτρολογήστε ένα όνομα ή αριθμό Παράλειψη Συνέχεια + Προσθήκη ατόμου Κλήση Λεπτομέρειες Αποθήκευση στη συλλογή + Κοινή χρήση Άνοιγμα πλαισίου πλοήγησης Επιλεγμένα: %d Εκκαθάριση @@ -79,6 +81,10 @@ Προώθηση Διαγραφή + Επιλέξτε έναν αριθμό τηλεφώνου + %s ∙ Προεπιλεγμένος + Μόνο μια φορά + Πάντα Επιλέχθηκαν: %d %1$d από %2$d αποτελέσματα Αποστολή ως ομαδικό μήνυμα @@ -87,7 +93,6 @@ Κάρτα επαφής Προγραμματίστηκε για Η χρονική στιγμή πρέπει να βρίσκεται στο μέλλον! - Πρέπει να ξεκλειδώσετε το QKSMS+ για να αποκτήσετε τη δυνατότητα προγραμματισμού μηνυμάτων Προστέθηκε στα προγραμματισμένα μηνύματα Γράψτε ένα μήνυμα… Αντιγραφή κειμένου @@ -119,6 +124,7 @@ Παραδόθηκε: %s Αποτυχία αποστολής. Πατήστε για νέα προσπάθεια Λεπτομέρειες + Η διεύθυνση αντεγράφη Τίτλος συζήτησης Ειδοποιήσεις Θέμα εμφάνισης @@ -137,7 +143,6 @@ Ποτέ Επαναφορά Επιλέξτε ένα αντίγραφο ασφαλείας - Παρακαλώ ξεκλειδώστε το QKSMS+ για τη δημιουργία και επαναφορά αντιγράφων ασφαλείας Δημιουργία αντιγράφου ασφαλείας… Επαναφορά αντιγράφου ασφαλείας… Επαναφορά αντιγράφου ασφαλείας @@ -180,6 +185,7 @@ Νυχτερινή λειτουργία καθαρού μαύρου Ώρα έναρξης Ώρα λήξης + Αυτόματος χρωματισμός επαφών Μέγεθος γραμματοσειράς Χρήση γραμματοσειράς συστήματος Αυτόματα emoji @@ -190,6 +196,7 @@ Κουμπί 2 Κουμπί 3 Προεπισκοπήσεις ειδοποιήσεων + Ενεργοποίηση οθόνης Δόνηση Ήχος Κανένα @@ -213,12 +220,14 @@ Επιβεβαιώσεις παράδοσης Confirm that messages were sent successfully - Signature + Υπογραφή Add a signature to the end of your messages Strip accents Remove accents from characters in outgoing SMS messages Mobile numbers only When composing a message, only show mobile numbers + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Auto-compress MMS attachments Sync messages Re-sync your messages with the native Android SMS database @@ -236,9 +245,9 @@ Ενσωματωμένη λειτουργία φραγής του QKSMS Automatically filter your calls and messages in one convenient place! Community IQ™ allows you to prevent unwanted messages from community known spammers Automatically filter messages from unsolicited numbers by using the \"Should I Answer\" app - Copy blocked numbers + Αντιγραφή αριθμών με φραγή Continue to %s and copy over your existing blocked numbers - Blocked numbers + Αριθμοί με φραγή Your blocked numbers will appear here Block a new number Block texts from @@ -258,10 +267,7 @@ Σχετικά με την εφαρμογή Έκδοση - Προγραμματιστής Πηγαίος κώδικας - Αρχείο αλλαγών - Επικοινωνία Άδεια χρήσης Πνευματικά δικαιώματα Support development, unlock everything @@ -354,9 +360,10 @@ Long + Automatic 100KB 200KB - 300KB (Recommended) + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-es/strings.xml b/presentation/src/main/res/values-es/strings.xml index 0e7fc7c3c4efd8cadb6f451adff51422178f999b..41fb519331b12b9c99a62a174423f99bda875b77 100644 --- a/presentation/src/main/res/values-es/strings.xml +++ b/presentation/src/main/res/values-es/strings.xml @@ -30,9 +30,11 @@ Escriba un nombre o número Omitir Continuar + Add person Llamar Detalles Guardar en la galería + Share Abrir cajón de navegación %d Seleccionados Limpiar @@ -79,6 +81,10 @@ Reenviar Borrar + Choose a phone number + %s ∙ Default + Just once + Siempre %d seleccionados %1$d de %2$d resultados Enviar como mensaje de grupo @@ -118,6 +124,7 @@ Entregado %s No se envió. Pulse para volver a intentarlo Detalles + Address copied Título de la conversación Notificaciones Tema @@ -168,7 +175,7 @@ Enviar ahora Copy text - Delete + Borrar Apariencia General @@ -178,6 +185,7 @@ Modo noche negro puro Hora de inicio Hora de finalización + Automatic contact colors Tamaño de la fuente Usar fuente del sistema Emojis automáticos @@ -188,6 +196,7 @@ Botón 2 Botón 3 Previsualización de notificaciones + Wake screen Vibración Sonido Ninguno @@ -217,6 +226,8 @@ Quitar acentos de caracteres en los mensajes SMS salientes Sólo números móviles Al componer un mensaje, sólo mostrar números móviles + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Autocomprimir archivos adjuntos MMS Sincronizar mensajes Vuelve a sincronizar tus mensajes con la base de datos nativa de Android SMS @@ -311,8 +322,8 @@ Llamar Eliminar - Yes - Continue + + Continuar Cancelar Eliminar Guardar @@ -329,10 +340,10 @@ Mensaje no enviado El mensaje a %s no se pudo enviar - System - Disabled - Always on - Automatic + Sistema + Desactivado + Siempre activado + Automático Mostrar nombre y el mensaje @@ -352,13 +363,14 @@ Largo + Automatic 100KB 200KB - 300KB (recomendado) + 300KB 600KB 1000KB 2000KB - Sin compresión + No compression Ok diff --git a/presentation/src/main/res/values-fa/strings.xml b/presentation/src/main/res/values-fa/strings.xml index 4b638aa9e42052f3ebe1f4472671215c318b23d0..b1f85dcbc3907b2b340cc0acac0825c391df74bf 100644 --- a/presentation/src/main/res/values-fa/strings.xml +++ b/presentation/src/main/res/values-fa/strings.xml @@ -30,24 +30,26 @@ یک نام یا شماره را وارد کنید بیخیال ادامه + اضافه کردن شخص تماس جزئیات ذخیره در گالری + اشتراک گذاری بازکردن کشو ناوبری %d انتخاب شده پاکسازی بایگانی بیرون آوردن از بایگانی حذف - Add to contacts + افزودن به مخاطبین اتصال به بالای صفحه - Unpin + رها کردن علامت گذاری به عنوان خوانده شده علامت گذاری بعنوان خوانده نشده مسدود کردن همگام سازی پیام ها… شما: %s - Draft: %s + پیش نویس: %s نتایج در پیام ها %d پیام مکالمات اینجا نمایش داده می‌شود @@ -81,24 +83,28 @@ فوروارد حذف + یک شماره تلفن انتخاب کنید + %s ∙ پیش فرض + فقط یک بار + همیشه %d انتخاب شده - %1$d of %2$d results + نتایج %1$d از %2$d ارسال پیام به گروه - Recipients and replies will be visible to everyone - This is the start of your conversation. Say something nice! + دریافت کنندگان و پاسخ ها برای همه قابل مشاهده خواهد بود + این آغاز مکالمه شماست. یه چیز خوب بگو! کارت تماس Scheduled for Selected time must be in the future! Added to scheduled messages نوشتن پیام… کپی کردن متن - Forward + ارسال به دیگری حذف قبلی بعدی پاکسازی جزئیات پیام - Type: %s + نوع: %s از: %s به: %s موضوع: %s @@ -110,16 +116,17 @@ کد خطا: %d افزودن پیوست پیوست یک عکس - Take a photo + عکس گرفتن زمان بندی پیام - Attach a contact - Error reading contact + یک مخاطب ضمیمه کنید + خطا در خواندن مخاطب %s برگزیده شده، تغییر سیم کارت فرستادن پیام فرستادن… رسیده %s در ارسال خطایی رخ داد. برای ارسال دوباره، ضربه بزنید جزئیات + آدرس کپی شد عنوان گفتگو اعلانها پوسته @@ -128,7 +135,7 @@ مسدود کردن رفع مسدودیت حذف مکالمه - Couldn\'t load media + بارگیری رسانه امکان پذیر نیست ذخیره در گالری پشتیبان گیری و بازیابی پشتیبان گیری از پیام‌ها @@ -143,62 +150,64 @@ Restore from backup آیا مطمئن هسیتد که می‌خواهید پیغام‌هایتان را از پشتيباني بازیابی کنید? بازیابی را متوقف کن - Messages that have already been restored will remain on your device + پیام هایی که قبلاً بازیابی شده اند در دستگاه شما باقی خواهند ماند پشتیبان‌ها نسخه ی پشتیبان پیدا نشد - %d message - %d messages + %d پیام + %d پیام - Currently, only SMS is supported by Backup and Restore. MMS support and scheduled backups will be coming soon! + در حال حاضر ، فقط پیامک پشتیبان گیری و بازیابی پشتیبانی می شود. پشتیبان‌گیری و برنامه‌ریزی برای پشتیبان‌گیری از MMS به زودی ارائه می شود! همین حالا نسخه پشتیبان تهیه کن برگردانی بک اپ %d/%d پیام - Saving backup… - Syncing messages… - Finished! - Backup and restore - Scheduled - Automatically send a message, at the exact moment you\'d like - Hey! When was your birthday again? - It\'s on December 23rd - Happy birthday! Look at what a great friend I am, remembering your birthday + ذخیره نسخه پشتیبان… + در حال همگام سازی پیام‌ها… + تمام شد! + پشتیبان گیری و بازیابی + برنامه ریزی + در همان لحظه ای که می خواهید پیام بصورت خودکار ارسال کنید + سلام! کی دوباره تولدت بود؟ + 23 دسامبر است + تولدت مبارک! نگاه کنید که چه دوست خوبی دارم ، به یاد تولدت - Sending on December 23rd  - Schedule a message - Scheduled message + ارسال در 23 دسامبر + یک پیام تنظیم کنید + پیام برنامه ریزی شده ارسال هم‌اکنون - Copy text - Delete + کپی متن + حذف ظاهر عمومی - QK Reply + پاسخ QK پوسته حالت شب حالت شب کامل زمان شروع زمان پایان + رنگ مخاطب خودکار اندازه قلم از فونت سیستم استفاده کن ایموجی خودکار اعلان‌ها از فونت سیستم استفاده کن - Actions - Button 1 - Button 2 - Button 3 + اقدامات + دکمه 1 + دکمه 2 + دکمه 3 بازبینی اعلان + صفحه بیدار لرزش صدای هیچی پاسخ QK پنجره پیام های جدید برای رد کردن ضربه بزنید - Tap outside of the popup to close it + برای بستن آن ، به بیرون پنجره ضربه بزنید ارسال با تاخیر - Swipe actions + اقدامات کشیدن تنظیم کشیدن برای گفتگو به راست بکشید به چپ بکشید @@ -214,11 +223,13 @@ تأیید تحویل تأیید ارسال موفق پیام امضا - Add a signature to the end of your messages + به انتهای پیام های خود یک امضا اضافه کنید حذف لهجه‌ها حذف کاراکترهای اضافه پیام ارسالی فقط شماره های تلفن وقتی در حال ارسال پیام هستید فقط می توانید شماره تلفن را ببنید + پیام های طولانی را به عنوان MMS ارسال کن + اگر پیام های متنی طولانی شما نتوانسته اند ارسال شوند یا به ترتیب اشتباه ارسال شوند ، می توانید به جای آنها پیام های MMS ارسال کنید. هزینه های اضافی ممکن است اعمال شود فشرده سازی خودکار پیوست ها همگام سازی پیام ها همگام سازی پیام ها با پایگاه داده @@ -227,41 +238,38 @@ ورود به سیستم دیباگ فعال است ورد به سیستم دیباگ غیرفعال است زمان (ثانیه) را وارد کنید - Blocking - Drop messages - Drop incoming messages from blocked senders instead of hiding them - Blocked conversations - Blocking Manager + انسداد + دور انداختم پیام‌ها + پیام های ورودی را از فرستنده های مسدود شده به جای مخفی کردن آنها دور اندازید + مکالمات مسدود شده + مدیریت انسداد QKSMS - Built-in blocking functionality in QKSMS - Automatically filter your calls and messages in one convenient place! Community IQ™ allows you to prevent unwanted messages from community known spammers + عملکرد مسدود کننده داخلی در QKSMS + تماس ها و پیام های خود را بطور خودکار فیلتر کنید! IQ Community به شما اجازه می دهد تا از پیام های ناخواسته توسط اسپم های شناخته شده در جامعه جلوگیری کنید با استفاده از برنامه «باید جواب بده»، پیامها را از شماره های ناخواسته به طور خودکار فیلتر کنید - Copy blocked numbers - Continue to %s and copy over your existing blocked numbers - Blocked numbers - Your blocked numbers will appear here - Block a new number - Block texts from - Phone number - Block - Blocked messages - Your blocked messages will appear here - Block - Unblock + کپی شماره های مسدود شده + به %s ادامه دهید و از شماره های مسدود شده موجود خود کپی کنید + شماره‌های مسدود شده + شماره های مسدود شده شما در اینجا ظاهر می شوند + انسداد شماره جدید + انسداد پیام از طرف + شماره تلفن + انسداد + پیام‌های مسدود شده + پیامهای مسدود شده شما در اینجا ظاهر می شوند + انسداد + رفع مسدوديت - Continue to %s and block this number - Continue to %s and block these numbers + به %s ادامه دهید و این شماره را مسدود کن + به %s ادامه دهید و این شماره‌ها را مسدود کن - Continue to %s and allow this number - Continue to %s and allow these numbers + به %s ادامه دهید و به این شماره اجازه بده + به %s ادامه دهید و به این شماره‌ها اجازه بده درباره نسخه - توسعه دهنده کد منبع - Changelog - مخاطب مجوز حق نشر حمایت از تولید کننده،بازشدن همه چیز @@ -313,8 +321,8 @@ تماس پاک کردن - Yes - Continue + بله + ادامه لغو حذف ذخیره @@ -354,13 +362,14 @@ طولانی - ۱۰۰KB - ۲۰۰KB - 300 کیلوبایت(توصیه شده) - ۶۰۰KB - ۱۰۰۰KB - ۲۰۰۰KB - بدون فشردگی + خودکار + ۱۰۰ کیلوبایت + ۲۰۰ کیلوبایت + ۳۰۰ کیلوبایت + ۶۰۰ کیلوبایت + ۱۰۰۰ کیلوبایت + ۲۰۰۰ کیلوبایت + بدون فشرده‌سازی باشه diff --git a/presentation/src/main/res/values-fi/strings.xml b/presentation/src/main/res/values-fi/strings.xml index 2d6d13a08a09af72a014218022fa15f14b87c452..39f8c3da3f94b01f2377f4a8e866d54bc2bf046c 100644 --- a/presentation/src/main/res/values-fi/strings.xml +++ b/presentation/src/main/res/values-fi/strings.xml @@ -30,9 +30,11 @@ Kirjoita nimi tai numero Ohita Jatka + Add person Soita Lisätiedot Tallenna galleriaan + Share Avaa navigointilaatikko %d valittu Tyhjennä @@ -83,6 +85,10 @@ Edelleenlähetä Poista + Choose a phone number + %s ∙ Default + Just once + Always %d valittu %1$d / %2$d tuloksesta Lähetä ryhmäviestinä @@ -91,7 +97,6 @@ Käyntikortti Ajoitettu Valitun ajan täytyy olla tulevaisuudessa! - Sinun täytyy avata QKSMS+ ajoittaaksesi viestejä Viesti ajoitettu Kirjoita viesti… Kopioi teksti @@ -123,6 +128,7 @@ Toimitettu: %s Lähettäminen epäonnistui. Yritä uudelleen napauttamalla Lisätiedot + Address copied Keskustelun otsikko Ilmoitukset Teema @@ -141,7 +147,6 @@ Ei koskaan Palauta Valitse varmuuskopio - Ole hyvä ja avaa QKSMS+ käyttääksesi varmuuskopiointia ja palautusta Varmuuskopiointi käynnissä… Palautus käynnissä… Palauta varmuuskopio @@ -184,6 +189,7 @@ Musta yötilä Aloitusaika Lopetusaika + Automatic contact colors Fonttikoko Käytä järjestelmäfonttia Automaattinen emoji @@ -194,6 +200,7 @@ Nappi 2 Nappi 3 Esikatselut ilmoituksessa + Wake screen Värinä Ääni Ei mitään @@ -223,6 +230,8 @@ Käytä yksinkertaisia merkkejä lähtevissä SMS-viesteissä Vain matkapuhelinnumerot When composing a message, only show mobile numbers + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Pakkaa MMS-liitteet automaattisesti Synkronoi viestit Re-sync your messages with the native Android SMS database @@ -262,10 +271,7 @@ Tietoja Versio - Kehittäjä Lähdekoodi - Muutosloki - Yhteystiedot Lisenssi Tekijänoikeus Tue kehitystä, avaa kaikki @@ -358,13 +364,14 @@ Pitkä + Automatic 100KB 200KB - 300KB (Suositeltu) + 300KB 600KB 1000KB 2000KB - Ei pakkausta + No compression Kyllä diff --git a/presentation/src/main/res/values-fr/strings.xml b/presentation/src/main/res/values-fr/strings.xml index 9db9cf359cef15bab1dd869d1bfc60b4c06df415..880402a2933a6950d994a5b0cf43066eccb48fa4 100644 --- a/presentation/src/main/res/values-fr/strings.xml +++ b/presentation/src/main/res/values-fr/strings.xml @@ -28,9 +28,11 @@ Entrer un nom ou un numéro Ignorer Continuer + Ajouter une personne Appeler Détails Enregistrer dans la galerie + Partager Ouvrir le tiroir de navigation %d sélectionnée Effacer @@ -81,6 +83,10 @@ Transférer Supprimer + Choisir un numéro de téléphone + %s • Défaut + Une seule fois + Toujours %d sélectionnée %1$d sur %2$d résultats Envoyer en tant que message groupé @@ -120,6 +126,7 @@ Délivré %s Échec de l\'envoi. Appuyer pour réessayer Détails + Adresse copiée Titre de la conversation Notifications Thème @@ -180,6 +187,7 @@ Mode noir pur Heure de début Heure de fin + Couleurs de contact automatiques Taille de la police Utiliser la police du système Émoticône automatique @@ -190,6 +198,7 @@ Bouton 2 Bouton 3 Aperçus de notification + Réveiller l\'écran Vibration Son Aucun @@ -219,6 +228,8 @@ Supprimer les accents des caractères lors de l\'envoi de messages SMS Numéros de mobile Lorsque vous composez un message, afficher uniquement les numéros de mobile + Envoyer les messages longs en MMS + Si vos messages texte plus longs ne parviennent pas à être envoyés, ou s\'ils sont envoyés dans le désordre, vous pouvez les envoyer sous forme de messages MMS à la place. Des frais supplémentaires peuvent s\'appliquer Compresser automatiquement les pièces jointes des MMS Synchroniser les messages Resynchroniser les messages avec la base de données Android native @@ -323,13 +334,14 @@ Long - 100 Ko - 200 Ko - 300 Ko (recommandé) - 600 Ko - 1 000 Ko - 2 000 Ko - Aucune compression + Automatique + 100KB + 200KB + 300KB + 1000KB + 1000KB + 2000KB + Pas de compression OK diff --git a/presentation/src/main/res/values-hi/strings.xml b/presentation/src/main/res/values-hi/strings.xml index ca414ee5954ad4ead3a3ce0eb40dc45e734a34bf..667ca706a1b69df0f3e122ad22215bfd6c9599e1 100644 --- a/presentation/src/main/res/values-hi/strings.xml +++ b/presentation/src/main/res/values-hi/strings.xml @@ -30,9 +30,11 @@ रचना छोड़ जारी रखें + Add person कॉल विवरण गैलरी में सहेजें + Share खुला नेविगेशन दराज %d चयनित साफ करें @@ -79,8 +81,15 @@ अग्रेषित करें मिटाएँ - > + + + + Choose a phone number + %s ∙ Default + Just once + Always + %d चयनित %1$d के %2$d परिणाम समूह संदेश के रूप में भेजें @@ -89,7 +98,6 @@ संपर्क कार्ड के लिए निर्धारित चयनित समय भविष्य में होना चाहिए! - आप QKSMS + अनुसूचित संदेश का उपयोग करने के लिए अनलॉक करना होगा अनुसूचित संदेशों में जोड़ा गया संदेश लिखें पाठ की प्रतिलिपि बनाएं @@ -121,6 +129,7 @@ %s वितरित भेजने में असफल। पुनः प्रयास करें विवरण + Address copied Conversation title अधिसूचनाएँ थीम @@ -139,7 +148,6 @@ कभी नहीं Restore किसी बैकअप का चयन करें - Please unlock QKSMS+ to use backup and restore Backup in progress… Restore in progress… Restore from backup @@ -182,6 +190,7 @@ शुद्ध काली रात अंदाज़ शुरुआत समय अंत समय + Automatic contact colors लिखाई का आकर यंत्र ध्वनियां उपयोग करें Automatic emoji @@ -192,6 +201,7 @@ बटन दो बटन तीन अधिसूचना सेटिंग्स + Wake screen कंपन ध्वनियाँ कुछ नहीं @@ -221,6 +231,8 @@ Remove accents from characters in outgoing SMS messages Mobile numbers only When composing a message, only show mobile numbers + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Auto-compress MMS attachments Sync messages Re-sync your messages with the native Android SMS database @@ -260,10 +272,7 @@ About संस्करण - Developer श्रोत कोड - बदलाव - संपर्क लाइसेंस Support development, unlock everything You can save a starving developer for just %s @@ -355,12 +364,13 @@ बड़ा - 100 किलो बाइट - 200 किलो बाइट - 300 किलो बाइट(सुझाव) - 600 किलो बाइट - 1000 किलो बाइट - 2000 किलो बाइट + Automatic + 100KB + 200KB + 300KB + 600KB + 1000KB + 2000KB No compression diff --git a/presentation/src/main/res/values-hr/strings.xml b/presentation/src/main/res/values-hr/strings.xml index 40f62f5cd3bd82ae23f228495c63dbf610493ef9..cefdfab59eb0e3bda0b4659f8a90bff0692f3dc1 100644 --- a/presentation/src/main/res/values-hr/strings.xml +++ b/presentation/src/main/res/values-hr/strings.xml @@ -30,9 +30,11 @@ Unesite ime ili broj Preskoči Nastavi + Add person Nazovi Detalji Save to gallery + Share Open navigation drawer %d odabrano Očisti @@ -80,6 +82,10 @@ Proslijedi Izbriši + Choose a phone number + %s ∙ Default + Just once + Always %d odabrano %1$d of %2$d results Send as group message @@ -119,6 +125,7 @@ Isporučeno %s Slanje nije uspjelo. Dodirnite da biste pokušali ponovno Detalji + Address copied Conversation title Obavijesti Tema @@ -180,6 +187,7 @@ Potpuno crni noćni način Vrijeme početka Vrijeme završetka + Automatic contact colors Veličina fonta Koristi sustavski font Automatsk emoji @@ -190,6 +198,7 @@ Button 2 Button 3 Pregledi obavijesti + Wake screen Vibracija Zvuk None @@ -219,6 +228,8 @@ Uklonite naglaske sa znakova u odlaznim SMS porukama Mobile numbers only When composing a message, only show mobile numbers + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Auto-compress MMS attachments Uskladi poruke Ponovno uskladite svoje poruke sa nativnom Android SMS bazom podataka @@ -357,9 +368,10 @@ Long + Automatic 100KB 200KB - 300KB (Recommended) + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-hu/strings.xml b/presentation/src/main/res/values-hu/strings.xml index 0fb315d44d8a1b4921b65b1a9e265eda7f65bab0..d59221e5bbca7e4eb649660c320a4e8c786b84a5 100644 --- a/presentation/src/main/res/values-hu/strings.xml +++ b/presentation/src/main/res/values-hu/strings.xml @@ -30,16 +30,18 @@ Adj meg egy nevet vagy telefonszámot Kihagy Tovább + Személy hozzáadása Hívás Részletek Mentés a galériába + Megosztás Navigációs kiválasztása %d kijelölve Törlés Archiválás Archiválás visszavonása Törlés - Add to contacts + Hozzáadás az ismerősökhöz Rögzítés a tetejére Rögzítés törlése Megjelölés olvasottként @@ -47,7 +49,7 @@ Tiltás Üzenetek szinkronizálása… Te: %s - Draft: %s + Vázlat: %s Eredmények az üzenetek szövegében %d üzenet A beszélgetéseid itt jelennek meg @@ -81,6 +83,10 @@ Továbbítás Törlés + Válassz egy telefonszámot + %s · Alapértelmezett + Csak egyszer + Mindig %d kijelölve %1$d. a %2$d találatból Küldés csoportos üzenetként @@ -120,6 +126,7 @@ Kézbesítve %s Sikertelen küldés. Érintsd meg az újraküldéshez Részletek + Cím átmásolva A beszélgetés témája Értesítések Téma @@ -170,8 +177,8 @@ Időzített üzenet Küldés most - Copy text - Delete + Szöveg másolása + Törlés Megjelenés Általános @@ -181,6 +188,7 @@ Éjfekete mód Kezdés ideje Befejezés ideje + Ismerősök automatikus színezése Betűméret Rendszer betűtípus visszaállítása Automatikus emoji @@ -191,6 +199,7 @@ Gomb 2 Gomb 3 Értesítés előnézet + Képernyő felébresztése Rezgés Hang Nincs @@ -215,11 +224,13 @@ Elküldés megerősítése Megerősíti, hogy az üzenetek sikeresen el lettek-e küldve Aláírás - Add a signature to the end of your messages + Aláírás az üzenet végére Ékezetek törlése A kimenő SMS-üzenetek ékezetes karaktereinek átalakítása Csak mobilszámok Üzenet írásánál csak a mobilszámokat mutassa + A hosszú üzenetek MMS-ként küldése + Ha a hosszabb üzenetek küldése sikertelen, vagy rossz sorrendben érkeznek akkor lehet őket MMS-ként küldeni, de ez díjköteles lehet MMS-mellékletek automatikus tömörítése Üzenetek szinkronizálása Üzenetek újra-szinkronizálása a natív Android SMS-adatbázissal @@ -228,7 +239,7 @@ Hibanaplózás engedélyezve Hibanaplózás tiltva Nyomva tartás ideje (másodpercben) - Blocking + Blokkolás Üzenetek eldobása Üzenetek eldobása a blokkolt küldőktől az elrejtésük helyett Tiltott társalgások @@ -244,11 +255,11 @@ Új szám tiltása Üzenetek tiltása: Telefonszám - Block + Blokkolás Tiltott üzenetek A tiltott üzenetek itt fognak megjelenni - Block - Unblock + Blokkolás + Visszaengedélyezés %s és ennek a számnak a blokkolása %s és ezeknek a számoknak a tiltása @@ -314,7 +325,7 @@ Törlés Igen - Continue + Folytatás Mégsem Törlés Mentés @@ -355,13 +366,14 @@ Hosszú + Automatic 100KB 200KB - 300KB (Javsolt) + 300KB 600KB 1000KB 2000KB - Nincs tömörítés + No compression Oké diff --git a/presentation/src/main/res/values-in/strings.xml b/presentation/src/main/res/values-in/strings.xml index a07b8f2cfb5b3658d20596f09af3a44adb112e5a..ac7f4762ca92cdfe83d8114d21040573b7f50a16 100644 --- a/presentation/src/main/res/values-in/strings.xml +++ b/presentation/src/main/res/values-in/strings.xml @@ -30,9 +30,11 @@ Ketik nama atau nomor Lewati Lanjut + Add person Panggil Detail Simpan ke galeri + Berbagi Buka laci navigasi %d dipilih Bersihkan @@ -80,6 +82,10 @@ Teruskan Hapus + Pilih nomor telepon + %s ∙ Default + Hanya sekali + Selalu %d dipilih %1$d dari %2$d hasil Kirim sebagai pesan grup @@ -119,6 +125,7 @@ %s terkirim Gagal mengirim. Ketuk untuk mencoba lagi Detail + Alamat disalin Judul percakapan Notifikasi Tema @@ -179,6 +186,7 @@ Mode malam hitam pekat Waktu mulai Waktu berakhir + Warna kontak otomatis Ukuran fon Gunakan fon sistem Emoji otomatis @@ -189,6 +197,7 @@ Tombol 2 Tombol 3 Pratinjau notifikasi + Layar ambien Getar Suara Tidak ada @@ -213,11 +222,13 @@ Konfirmasi pengiriman Mengonfirmasi bahwa pesan telah berhasil dikirim Tanda tangan - Add a signature to the end of your messages + Tambahkan tanda tangan di akhir pesan anda Hapus aksen Membuang aksen dari karakter di dalam pesan SMS keluar Hanya nomor seluler Saat menulis pesan, hanya tampilkan nomor seluler + Kirim pesan panjang sebagai MMS + Jika pesan teks panjang anda gagal dikirim, atau dikirim dalam urutan yang salah, anda bisa mengirimkannya sebagai pesan MMS. Mungkin akan dikenai tagihan tambahan Otomatis kompres lampiran MMS Sinkronisasi pesan Sinkronisasi ulang pesan anda dengan basis data aplikasi SMS Android bawaan @@ -350,9 +361,10 @@ Lama + Otomatis 100KB 200KB - 300KB (Direkomendasikan) + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-it/strings.xml b/presentation/src/main/res/values-it/strings.xml index d2b2ad9aa07c84d18a486f9c30833926a9f8e61e..78a3af64c7294d9c33ae9ba482bac383f13249d0 100644 --- a/presentation/src/main/res/values-it/strings.xml +++ b/presentation/src/main/res/values-it/strings.xml @@ -28,10 +28,12 @@ Digita un nome o un numero Salta Continua + Aggiungi persona Chiama Dettagli Salva nella galleria Apri barra di navigazione + Condividi %d selezionato Pulisci Archivia @@ -79,7 +81,11 @@ Inoltra Elimina - %d selezionati + Scegli un numero di telefono + %s ∙ Predefinito + Solo una volta + Sempre + %d selezionato %1$d di %2$d risultati Invia come messaggio di gruppo I destinatari e le risposte saranno visibili a tutti @@ -118,6 +124,7 @@ %s consegnati Invio non riuscito. Tocca per riprovare Dettagli + Indirizzo copiato Titolo della conversazione Notifiche Tema @@ -178,6 +185,7 @@ Modalità nero notte profonda Ora inizio Ora fine + Colori dei contatti automatici Dimensione carattere Usa il carattere di sistema Emoji automatico @@ -188,6 +196,7 @@ Pulsante 2 Pulsante 3 Anteprima delle notifiche + Schermata iniziale Vibrazione Suono Nessuno @@ -217,6 +226,9 @@ Rimuovi gli accenti dai caratteri negli SMS in uscita Solo numeri di telefoni cellulari Durante la composizione di un messaggio, visualizza solo i numeri di telefoni cellulari + Invia messaggi lunghi come MMS + Se i tuoi messaggi di testo più lunghi non vengono inviati o vengono inviati nell\'ordine sbagliato, puoi invece inviarli come messaggi MMS. Potrebbero essere applicati costi aggiuntivi +>>>>>>> 3f4693ee... Update translations Compressione automatica degli allegati MMS Sincronizza i messaggi Risincronizza i tuoi messaggi con il database SMS Android nativo @@ -256,10 +268,7 @@ Informazioni Versione - Sviluppatore Codice sorgente - Elenco delle modifiche - Contatto Licenza Copyright Supporta lo sviluppo, sblocca tutte le funzioni @@ -352,9 +361,10 @@ Lungo + Automatico 100KB 200KB - 300KB (consigliato) + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-iw/strings.xml b/presentation/src/main/res/values-iw/strings.xml index 2bf6ae13384b42b99ee3dd48f8b16fe57159de41..e25d9517367196cdf880b314d62ad9d7f14a0a6f 100644 --- a/presentation/src/main/res/values-iw/strings.xml +++ b/presentation/src/main/res/values-iw/strings.xml @@ -30,9 +30,11 @@ כתוב הודעה דילוג המשך + להוסיף מישהו התקשר פרטים שמירה לגלריה + שיתוף פתיחת מגירת ניווט %d נבחרו פינוי @@ -81,6 +83,10 @@ העברה מחיקה + נא לבחור מספר טלפון + %s ∙ בררת מחדל + פעם אחד בלבד + תמיד %d נבחרו %1$d מתוך %2$d תוצאות שליחה כהודעה קבוצתית @@ -120,6 +126,7 @@ %s נמסרה השליחה נכשלה. נא לגעת כדי לנסות שוב פרטים + הכתובת הועתקה כותרת התכתבות התראות ערכת עיצוב @@ -182,6 +189,7 @@ מצב לילה שחור טהור זמן התחלה זמן סיום + צבעים אוטומטיים לאנשי קשר גודל גופן שימוש בגופן המערכת אימוג׳י אוטומטי @@ -192,6 +200,7 @@ כפתור 2 כפתור 3 תצוגות מקדימות של התראות + הפעלת המסך רטט צליל ללא @@ -221,6 +230,8 @@ הסרת סימנים דיאקריטיים מתווים במסרונים יוצאים מספרי ניידים בלבד בעת כתיבת הודעה, להציג רק מספרי טלפון של ניידים + לשלוח הודעות ארוכות כ־MMS + אם יש לך בעיה בשליחת הודעות טקסט ארוכות יותר או בעיה ברצף שליחת ההודעה, ניתן לשלוח כהודעת MMS במקום. עלול לגרור עלויות נוספות דחיסה אוטומטית של קבצים מצורפים להודעות MMS סנכרון הודעות סנכרון ההודעות שלך מחדש עם מסד הנתונים המובנה של Android @@ -362,9 +373,10 @@ ארוכה + אוטומטי 100 ק״ב 200 ק״ב - 300 ק״ב (מומלץ) + 300 ק״ב 600 ק״ב 1000 ק״ב 2000 ק״ב diff --git a/presentation/src/main/res/values-ja/strings.xml b/presentation/src/main/res/values-ja/strings.xml index dc47ca85ac06b676e2e1118c655f9cb89b1a850d..c8c21eac12e38fe5476d0a1c70bff8ad2ab0b778 100644 --- a/presentation/src/main/res/values-ja/strings.xml +++ b/presentation/src/main/res/values-ja/strings.xml @@ -30,9 +30,11 @@ 作成 スキップ 続行 + 人を追加 通話 詳細 ギャラリーに保存 + 共有 ナビゲーション引き出しを開く %d 選択された 消去しました @@ -78,6 +80,10 @@ 転送 削除 + 電話番号を選択 + %s ∙ デフォルト + 一回だけ + 常に %d件を選択中 %2$d 結果の %1$d グループ メッセージとして送信します。 @@ -117,6 +123,7 @@ %s を配信しました 送信に失敗しました。タップすると、もう一度やり直します 詳細 + アドレスをコピーしました 会話のタイトル 通知 テーマ @@ -176,6 +183,7 @@ ピュアブラック夜間モード 開始時刻 終了時刻 + 自動的に連絡先に色をつける フォントサイズ システムフォントを使用する 自動絵文字 @@ -186,6 +194,7 @@ ボタン 2 ボタン 3 通知プレビュー + ウェイクスクリーン 振動 サウンド 無し @@ -215,6 +224,8 @@ 送信する SMS メッセージの文字からアクセントを削除します 携帯電話番号のみ メッセージを作成するときは、携帯電話番号のみを表示します + 長いメッセージをMMSとして送信する + 長いテキストメッセージの送信が失敗する場合、または間違った順序で送信される場合は、代わりにMMSメッセージとして送信できます。 追加料金が適用される場合があります MMS添付ファイルの自動圧縮 メッセージを同期 メッセージをネイティブの Android SMS データベースと再同期します @@ -347,9 +358,10 @@ 長い + 自動 100KB 200KB - 300KB 【推奨】 + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-ko/strings.xml b/presentation/src/main/res/values-ko/strings.xml index 34e17c462589842e5983f8b1963afe0c7363ee9e..ebcd6926216aa18956b62180363ea7f3baa5895a 100644 --- a/presentation/src/main/res/values-ko/strings.xml +++ b/presentation/src/main/res/values-ko/strings.xml @@ -30,16 +30,18 @@ 이름 또는 전화번호 입력 건너뛰기 계속 + 대화 상대 추가하기 전화 자세히 갤러리에 저장하기 + 공유 메뉴 열기 %d 개가 선택됨 삭제하기 보관하기 - 보관하지 않기 + 보관 해제 지우기 - Add to contacts + 연락처에 추가 상단에 고정하기 고정 해제하기 읽음으로 표시 @@ -47,15 +49,20 @@ 차단 메시지 동기화 하기 나: %s - Draft: %s + 임시 메시지: %s 메시지 검색 결과 %d 개의 새로운 메시지 대화가 여기에 나타납니다 결과없음 보관된 메시지가 여기 나타납니다 새로운 대화 시작하기 + 다시 문자 보내는게 좋네요 Message 앱을 기본 메시지 앱으로 설정하기 + + + + 변경하기 권한을 부여해 주세요 Message 앱은 메시지를 보내고 읽을 수 있는 권한이 필요합니다 @@ -71,20 +78,22 @@ 친구 초대하기 삭제 - 정말 이 대화를 삭제할까요? - -%d 개의 대화를 삭제할까요? + 정말 %d 개의 대화를 삭제할까요? 문자 복사하기 전달 삭제 + 전화번호 선택하기 + %s ∙ 기본 + 한 번만 + 항상 %d 개 선택됨 %2$d 중 %1$d 결과 그룹 메시지 보내기 - 받는 사람 및 답장이 모든 사람에게 표시 됩니다. - 새로운 대화가 시작되었어요! 인사말을 건네는 건 어떨까요? + 받는 사람 및 답장이 모든 사람에게 표시됩니다. + 새로운 대화가 시작되었어요! 재미있는 대화를 해보세요! 연락처 예정 선택한 시간은 반드시 현재 이후의 시간으로 설정해야 합니다 @@ -98,8 +107,8 @@ 삭제 세부 정보 종류: %s - %s가 - %s에게 + %s 가 + %s 에게 제목: %s 우선 순위: %s 크기: %s @@ -119,6 +128,7 @@ %s 전송됨 전송 실패, 눌러서 다시 시도하세요 자세히 + Address copied 대화 제목 알림 테마 @@ -128,7 +138,7 @@ 차단 해제 대화 삭제 미디어를 불러올 수 없습니다 - 갤러리에 저장 + 갤러리에 저장하기 백업과 복원 메시지 백업하기 복원하기 @@ -158,19 +168,19 @@ 백업과 복원 예약됨 원하는 그 시간에 자동으로 메시지를 보냅니다. - 잠깐만요! 생일이 언제였나요..? - 12 월 23 일 입니다 - 생일 축하해요! 당신의 생일을 기억하는 멋진 친구죠..? + 잠깐만! 생일이 언제였더라..? + 12월 23일이야 + 생일 축하해! 너의 생일까지 기억해주는 이렇게 좋은 친구가 어디있겠어ㅋㅋ - 12 월 23 일에 보냅니다. + 12월 23일에 보냅니다. 메시지 예약 전송하기 예약된 메시지 지금 보내기 - Copy text - Delete + 텍스트 복사하기 + 삭제 - 외관 + 디자인 일반 설정 빠른 응답 테마 @@ -178,6 +188,7 @@ 완전한 블랙 테마 시작 시간 끝나는 시간 + Automatic contact colors 글꼴 크기 시스템 글꼴 사용 자동 이모티콘 @@ -188,6 +199,7 @@ 버튼 2 버튼 3 알림 미리 보기 + Wake screen 진동 소리 없음 @@ -211,41 +223,43 @@ 전송 확인 전송 성공을 확인합니다 - Signature - Add a signature to the end of your messages + 서명 + 문자 메시지 마지막에 서명을 추가합니다 스트립 악센트 SMS를 주고받을 때, 대문자 없애기 전화번호만 보이기 메시지를 쓸 때, 전화번호만 보이게 하기 + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply 자동으로 MMS 첨부 파일 압축하기 - 메시지 동기화 하기 + 메시지 동기화하기 기본 안드로이드 SMS 데이터베이스에 다시 동기화하기 Message에 대해... 버전 정보 %s 디버그 로깅 설정됨 디버그 로깅 해제됨 기간 (초)을 입력 - Blocking - Drop messages - Drop incoming messages from blocked senders instead of hiding them - Blocked conversations - Blocking Manager + 차단 + 메시지 자동 삭제 + 차단된 사람으로부터 오는 메시지를 가리는 대신 삭제합니다 + 차단된 대화 + 차단 메니저 QKSMS - Built-in blocking functionality in QKSMS - Automatically filter your calls and messages in one convenient place! Community IQ™ allows you to prevent unwanted messages from community known spammers + 빌트인 차단기능이 있는 기본 필터. + 자동으오 전화와 메시지를 한 곳에서 필터링하세요! Community IQ™는 커뮤니티 기반으로 원하지 않는 스팸 메시지를 차단해줍니다. Should I Answer 앱을 이용해 필요없는 번호에서 보낸 메시지를 자동으로 필터링하기 - Copy blocked numbers - Continue to %s and copy over your existing blocked numbers - Blocked numbers - Your blocked numbers will appear here - Block a new number - Block texts from - Phone number - Block - Blocked messages - Your blocked messages will appear here - Block - Unblock + 차단된 번호 복사하기 + %s로 계속하고 이미 차단된 번호를 복사합니다. + 차단된 번호 + 차단한 번호가 여기에 나타납니다 + 새로운 번호 차단하기 + 차단할 번호 입력 + 전화번호 + 차단 + 차단된 메시지 + 차단된 메시지가 여기에 나옵니다. + 차단 + 차단해제 Continue to %s and block these numbers @@ -254,10 +268,7 @@ 정보 버전 - 개발자 소스 코드 - 변경 사항 - 연락처 라이선스 저작권 개발을 지원하고 모든 기능을 해제하세요. @@ -351,13 +362,14 @@ 길게 - 100 KB - 200 KB - 300 KB (권장) - 600 KB - 1000 KB - 2000 KB - 압축 안 함 + Automatic + 100KB + 200KB + 300KB + 600KB + 1000KB + 2000KB + No compression 확인 diff --git a/presentation/src/main/res/values-lt/strings.xml b/presentation/src/main/res/values-lt/strings.xml index e846e7ed86c3d937b7f2f8304bec3a22e8e197e2..069a20fe01001a907634b7c2fdfea7e4b386f777 100644 --- a/presentation/src/main/res/values-lt/strings.xml +++ b/presentation/src/main/res/values-lt/strings.xml @@ -30,9 +30,11 @@ Rašyti Praleisti Tęsti + Add person Skambinti Dėtalės Save to gallery + Share Open navigation drawer %d pasirinkta(s) Ištrinti @@ -81,6 +83,10 @@ Persiųsti Ištrinti + Choose a phone number + %s ∙ Default + Just once + Always %d parinktas %1$d of %2$d results Send as group message @@ -120,6 +126,7 @@ Nusiųsta %s Nepavyko nusiųsti. Spausk dar kartą norint persiųsti Dėtalės + Address copied Conversation title Pranešimai Tema @@ -182,6 +189,7 @@ Visiškai juoda nakties tema Pradžios laikas Pabaigos laikas + Automatic contact colors Šrifto dydis Naudoti sistemos šriftą Automatinis emoji @@ -192,6 +200,7 @@ Button 2 Button 3 Pranešimų peržiūra + Wake screen Vibracija Garas None @@ -221,6 +230,8 @@ Ištrinti jūsų SMS žinučių simbolių akcentus Mobile numbers only When composing a message, only show mobile numbers + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Auto-compress MMS attachments Sinchronizuoti pranešimus Iš naujo sinchronizuoti Android pranešimų duomenų bazę @@ -264,11 +275,8 @@ Apie Versija - Kūrėjas Šaltinio kodas - Pakeitimų sąrašas - Kontaktai - Licencija +\ Licencija Autorinės teisės Remti programos vystymąsi, atrakinti viską Galite išsaugoti badaujantį programėlės kūrėją tik už %s @@ -362,9 +370,10 @@ Ilgas + Automatic 100KB 200KB - 300KB (Recommended) + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-nb/strings.xml b/presentation/src/main/res/values-nb/strings.xml index 39c34cf07db36fab92c05c3737ea1acba31e88a1..054f07d36c386233023f9b28684313749c0b2ccf 100644 --- a/presentation/src/main/res/values-nb/strings.xml +++ b/presentation/src/main/res/values-nb/strings.xml @@ -30,9 +30,11 @@ Skriv navn eller nummer Hopp over Fortsett + Add person Ring Detaljer Lagre til galleri + Share Åpne navigasjonsskuff %d valgt Tøm @@ -79,6 +81,10 @@ Videresend Slett + Choose a phone number + %s ∙ Default + Just once + Always %d valgt %1$d av %2$d funn Send som gruppemelding @@ -119,6 +125,7 @@ Levert %s Feil ved sending. Trykk for å prøve igjen Detaljer + Address copied Tittel på samtalen Varsler Drakt @@ -180,6 +187,7 @@ Ren svart nattmodus Starttid Sluttidspunkt + Automatic contact colors Skriftstørrelse Bruk systemskrift Automatisk emoji @@ -190,6 +198,7 @@ Knapp 2 Knapp 3 Forhåndsvisninger av varsler + Wake screen Vibrasjon Lyd Ingen @@ -219,6 +228,8 @@ Ta bort akutt-tegn fra utgående meldinger Kun mobilnummre Vis kun mobilnummre når du skriver ny melding + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Komprimer MMS-vedlegg Synkroniser meldinger Resynkroniser meldinger med Androids interne meldingslager @@ -355,13 +366,14 @@ Lang - 100 KB - 200 KB - 300 KB (anbefalt) - 600 KB - 1000 KB - 2000 KB - Ingen komprimering + Automatic + 100KB + 200KB + 300KB + 600KB + 1000KB + 2000KB + No compression Ok diff --git a/presentation/src/main/res/values-ne/strings.xml b/presentation/src/main/res/values-ne/strings.xml index 5cfce13a685518720426052b7fab1a47ccf9afd1..cde62f7544492badea25c16f3beac2880821c7fc 100644 --- a/presentation/src/main/res/values-ne/strings.xml +++ b/presentation/src/main/res/values-ne/strings.xml @@ -30,9 +30,11 @@ Compose Skip Continue + Add person सम्पर्क Details Save to gallery + Share Open navigation drawer %d selected Clear @@ -79,6 +81,10 @@ Forward Delete + Choose a phone number + %s ∙ Default + Just once + Always %d selected %1$d of %2$d results Send as group message @@ -118,6 +124,7 @@ Delivered %s Failed to send. Tap to try again Details + Address copied Conversation title Notifications Theme @@ -178,6 +185,7 @@ Pure black night mode Start time End time + Automatic contact colors Font size Use system font Automatic emoji @@ -188,6 +196,7 @@ Button 2 Button 3 Notification previews + Wake screen Vibration Sound None @@ -217,6 +226,8 @@ Remove accents from characters in outgoing SMS messages Mobile numbers only When composing a message, only show mobile numbers + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Auto-compress MMS attachments Sync messages Re-sync your messages with the native Android SMS database @@ -352,9 +363,10 @@ Long + Automatic 100KB 200KB - 300KB (Recommended) + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-night/themes.xml b/presentation/src/main/res/values-night/themes.xml index ed26b3c64a99b52e147787a3cdb45ebc195d0eec..b26faa8b7e743193d8682afbf88792341e15b182 100644 --- a/presentation/src/main/res/values-night/themes.xml +++ b/presentation/src/main/res/values-night/themes.xml @@ -24,8 +24,8 @@ @@ -68,21 +66,21 @@ @color/black @color/black @color/black + @color/bubbleBlack @color/black @color/black - @color/black diff --git a/presentation/src/main/res/values-nl/strings.xml b/presentation/src/main/res/values-nl/strings.xml index 30fd086988434b1a1af1e32e7c76ab80548fcbfc..d780daf9afd7cd5e6d749a4a1bfca34efdcf2ccf 100644 --- a/presentation/src/main/res/values-nl/strings.xml +++ b/presentation/src/main/res/values-nl/strings.xml @@ -30,9 +30,11 @@ Type een naam of nummer Overslaan Verder + Add person Bel Details Opslaan in galerij + Share Open navigatie lade %d geselecteerd Wissen @@ -81,6 +83,10 @@ Doorsturen Verwijderen + Choose a phone number + %s ∙ Default + Just once + Always %d geselecteerd %1$d van de %2$d resultaten Versturen als groepsbericht @@ -120,6 +126,7 @@ Afgeleverd %s Fout bij verzenden. Tik om opnieuw te proberen Details + Address copied Titel van conversatie Notificaties Thema @@ -180,6 +187,7 @@ Pure zwarte nachtmodus Starttijd Eindtijd + Automatic contact colors Tekst grootte Gebruik lettertype van systeem Automatische emoji @@ -190,6 +198,7 @@ Button 2 Button 3 Notificatievoorbeeld + Wake screen Trillen Geluid Geen @@ -219,6 +228,8 @@ Verwijder accenten van karakters voor uitgaande berichten Alleen GSM-nummers Enkel mobiele nummers tonen wanneer je een bericht opstelt + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Auto-compress MMS bijlagen Synchroniseer berichten Hersynchroniseer jouw berichten vanuit de SMS database op je telefoon @@ -354,13 +365,14 @@ Lang + Automatic 100KB 200KB - 300KB (aanbevolen) + 300KB 600KB 1000KB 2000KB - Geen compressie + No compression Oké diff --git a/presentation/src/main/res/values-pl/strings.xml b/presentation/src/main/res/values-pl/strings.xml index 56a91323459874f7f0689a483c8ea2ac36616c82..33f10e837d8d2479372429d9012c71384e259815 100644 --- a/presentation/src/main/res/values-pl/strings.xml +++ b/presentation/src/main/res/values-pl/strings.xml @@ -30,9 +30,11 @@ Nowa wiadomość Pomiń Kontynuuj + Dodaj osobę Zadzwoń Szczegóły Zapisz w galerii + Udostepnij Otwórz panel nawigacyjny %d wybrano Wyczyść @@ -85,6 +87,10 @@ Przekaż dalej Usuń + Wybierz numer telefonu + %s ∙ Domyślny + Tylko raz + Zawsze Wybrano %d Wyniki: %1$d z %2$d Wyślij jako wiadomość grupową @@ -124,6 +130,7 @@ Dostarczono %s Nie udało się wysłać wiadomości. Dotknij, aby spróbować ponownie Szczegóły + Adres został skopiowany Tytuł rozmowy Powiadomienia Motyw @@ -186,6 +193,7 @@ Czarne tło w trybie nocnym Czas rozpoczęcia Czas zakończenia + Automatyczne kolory kontaktów Rozmiar czcionki Używaj czcionki systemowej Automatyczne emoji @@ -196,6 +204,7 @@ Przycisk 2 Przycisk 3 Zawartość powiadomień + Wybudzaj ekran Wibracje Dźwięki Brak @@ -220,12 +229,14 @@ Potwierdzenie dostarczenia Potwierdzaj dostarczenie każdej wysłanej wiadomości Podpis - Add a signature to the end of your messages + Dodawaj podpis na końcu wiadomości Usuwaj ogonki Usuwaj ogonki ze znaków w wiadomościach Tylko numery komórkowe Pokazuj kontakty tylko z numerami komórkowymi - Automatyczna kompresja załączników MMSów + Wysyłaj długie wiadomości jako MMS + Jeśli dłuższe wiadomości tekstowe nie są wysyłane lub wysyłane są w złej kolejności, możesz wysyłać je jako wiadomości MMS. Mogą zostać naliczone dodatkowe opłaty + Kompresja załączników MMSów Synchronizuj wiadomości Zsynchronizuj wiadomości z systemową bazą danych O aplikacji Message @@ -243,7 +254,7 @@ Automatycznie filtruj twoje rozmowy i wiadomości tekstowe w jednym wygodnym miejscu! Community IQ™ pozwala zapobiegać niechcianym wiadomościom od znanych spamerów Automatycznie filtruj wiadomości od niechcianych numerów za pomocą aplikacji \"Should I Answer?\" Skopiuj zablokowane numery - Kontynuuj do %s kopiowanie twoich blokowanych numerów + Kontynuuj do %s i skopiuj twoje obecnie blokowane numery Zablokowane numery Twoje blokowane numery się tu pojawią Zablokuj nowy numer @@ -257,13 +268,13 @@ Kontynuuj do %s i blokuj ten numer Kontynuuj do %s i blokuj te numery - Continue to %s and block these numbers + Kontynuuj do %s i blokuj te numery Kontynuuj do %s i blokuj te numery Kontynuuj do %s i odblokuj ten numer Kontynuuj do %s i odblokuj te numery - Continue to %s and allow these numbers + Kontynuuj do %s i odblokuj te numery Kontynuuj do %s i odblokuj te numery O aplikacji @@ -366,12 +377,13 @@ Długi - 100KB - 200KB - 300KB (zalecane) - 600KB - 1000KB - 2000KB + Automatyczna + 100 KB + 200 KB + 300 KB + 600 KB + 1000 KB + 2000 KB Bez kompresji diff --git a/presentation/src/main/res/values-pt-rBR/strings.xml b/presentation/src/main/res/values-pt-rBR/strings.xml index 343f80309a4e6ce07fbd4b22152ecc2b14b326ec..5e9715bbbf8679aa94bdc42cfc7d8c6f4b6e9771 100644 --- a/presentation/src/main/res/values-pt-rBR/strings.xml +++ b/presentation/src/main/res/values-pt-rBR/strings.xml @@ -30,9 +30,11 @@ Nova Mensagem Pular Continuar + Adicionar pessoa Ligar Detalhes Salvar na galeria + Partilhar Abrir gaveta de navegação %d selecionada(s) Limpar @@ -79,6 +81,10 @@ Encaminhar Excluir + Escolha um número de telefone + %s ∙ Padrão + Uma vez + Sempre %d selecionada(s) %1$d de %2$d resultados Enviar como mensagem de grupo @@ -87,7 +93,6 @@ Cartão de contato Agendada para O tempo selecionado deve ser no futuro! - Você deve desbloquear o QKSMS+ para agendar mensagens Adicionada às mensagens agendadas Escrever uma mensagem… Copiar texto @@ -119,6 +124,7 @@ Entregue %s Falha ao enviar. Toque para tentar novamente Detalhes + Endereço copiado Título da conversa Notificações Tema @@ -137,7 +143,6 @@ Nunca Recuperar Selecione um backup - Por favor, desbloqueie o QKSMS+ para fazer backup e recuperar Backup em andamento… Recuperação em andamento… Recuperar do backup @@ -180,6 +185,7 @@ Modo noturno preto puro Hora de início Hora de término + Cores automáticas para os contactos Tamanho da fonte Usar fonte do sistema Emoji automático @@ -190,6 +196,7 @@ Botão 2 Botão 3 Pré-visualização de notificações + Ligar ecrã Vibração Som Nenhum @@ -219,6 +226,8 @@ Remover acentos de caracteres em mensagens SMS enviadas Somente números de celular Ao escrever uma mensagem, mostrar apenas os números de celular + Enviar mensagens longas como MMS + Se as mensagens longas não estiverem a ser enviadas ou estão a ser enviadas pela ordem errada, pode tentar enviar como MMS. Podem ser aplicados custos adicionais. Comprimir anexos MMS automaticamente Sincronizar mensagens Re-sincronizar suas mensagens com o banco de dados de SMS nativo do Android @@ -258,10 +267,7 @@ Sobre Versão - Desenvolvedor Código-fonte - Changelog - Contato Licença Copyright Apoie o desenvolvimento, desbloqueie tudo @@ -355,13 +361,14 @@ Longo - 100KB - 200KB - 300KB (Recomendado) - 600KB - 1000KB - 2000KB - Não comprimir + Automático + 100 KB + 200 KB + 300 KB + 600 KB + 1000 KB + 2000 KB + Sem compressão Ok diff --git a/presentation/src/main/res/values-pt/strings.xml b/presentation/src/main/res/values-pt/strings.xml index e3a54f897026fcb82405470e3397bea9610ab391..183372e84afeb28151dd0fbc02f4d56655a7a5c7 100644 --- a/presentation/src/main/res/values-pt/strings.xml +++ b/presentation/src/main/res/values-pt/strings.xml @@ -30,9 +30,11 @@ Digite um nome ou um número Ignorar Continuar + Adicionar pessoa Ligar Detalhes Guardar na galeria + Partilhar Abrir menu de navegação %d selecionada(s) Limpar @@ -83,6 +85,10 @@ Reencaminhar Apagar + Escolha um número de telefone + %s ∙ Padrão + Uma vez + Sempre %d selecionada(s) %1$d de %2$d resultados Enviar como mensagem de grupo @@ -122,6 +128,7 @@ %s entregue Falha ao enviar. Toque para tentar novamente Detalhes + Endereço copiado Título da conversa Notificações Tema @@ -182,6 +189,7 @@ Modo escuro puro Hora de início Hora de fim + Cores automáticas para os contactos Tamanho do texto Usar tipo de letra do sistema Emoji automático @@ -192,6 +200,7 @@ Botão 2 Botão 3 Pré-visualização de notificações + Ligar ecrã Vibração Som Nenhum @@ -221,6 +230,8 @@ Remover acentos dos caracteres nas mensagens enviadas Apenas números de telemóvel Ao escrever uma mensagem, mostrar apenas os números móveis + Enviar mensagens longas como MMS + Se as mensagens longas não estiverem a ser enviadas ou estão a ser enviadas pela ordem errada, pode tentar enviar como MMS. Podem ser aplicados custos adicionais. Comprimir anexos MMS automaticamente Sincronizar mensagens Sincronizar as mensagens com a base de dados nativa do Android @@ -356,13 +367,14 @@ Longo - 100KB - 200KB - 300KB (recomendado) - 600KB - 1000KB - 2000KB - Não comprimir + Automático + 100 KB + 200 KB + 300 KB + 600 KB + 1000 KB + 2000 KB + Sem compressão Ok diff --git a/presentation/src/main/res/values-ro/strings.xml b/presentation/src/main/res/values-ro/strings.xml index 019252c5daf10987dbecbd84dffa9018d2ff3509..9c515c1d78ab7bc4baf7f8e50844b50c42add277 100644 --- a/presentation/src/main/res/values-ro/strings.xml +++ b/presentation/src/main/res/values-ro/strings.xml @@ -30,9 +30,11 @@ Compune Sari Continue + Add person Apel Details Save to gallery + Share Open navigation drawer %d selected Clear @@ -80,6 +82,10 @@ Forward Delete + Choose a phone number + %s ∙ Default + Just once + Always %d selected %1$d of %2$d results Send as group message @@ -119,6 +125,7 @@ Delivered %s Failed to send. Tap to try again Details + Address copied Conversation title Notifications Theme @@ -180,6 +187,7 @@ Pure black night mode Start time End time + Automatic contact colors Font size Use system font Automatic emoji @@ -190,6 +198,7 @@ Button 2 Button 3 Notification previews + Wake screen Vibration Sound None @@ -219,6 +228,8 @@ Remove accents from characters in outgoing SMS messages Mobile numbers only When composing a message, only show mobile numbers + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Auto-compress MMS attachments Sync messages Re-sync your messages with the native Android SMS database @@ -357,9 +368,10 @@ Long + Automatic 100KB 200KB - 300KB (Recommended) + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-ru/strings.xml b/presentation/src/main/res/values-ru/strings.xml index 23ada9bbe81874344bab4e3ad2ca98fcc7772746..c35a8ebb6a5f92a83c98010fbd0b9ed4830f83d9 100644 --- a/presentation/src/main/res/values-ru/strings.xml +++ b/presentation/src/main/res/values-ru/strings.xml @@ -30,9 +30,11 @@ Написать Пропустить Продолжить + Добавить контакт Позвонить Подробности Сохранить в галерее + Поделиться Открыть боковое меню %d выбрано Очистить @@ -81,6 +83,10 @@ Переслать Удалить + Выбор номера телефона + %s ∙ по умолчанию + Один раз + Всегда %d выбран(о) %1$d из %2$d результатов Отправить как групповое сообщение @@ -121,6 +127,7 @@ Доставлено %s Не удалось отправить. Нажмите, чтобы попробовать ещё раз. Подробности + Адрес скопирован Название разговора Уведомления Тема @@ -184,6 +191,7 @@ Полностью чёрный ночной режим Время начала Время окончания + Автоматические цвета контактов Размер шрифта Использовать системный шрифт Автоматические emoji @@ -194,6 +202,7 @@ Кнопка 2 Кнопка 3 Предпросмотр уведомлений + Экран блокировки Вибросигнал Звук Нет @@ -223,6 +232,8 @@ Удаление акцентов с символов в исходящих SMS-сообщениях Только мобильные номера При составлении сообщения показывать только мобильные номера + Отправлять длинные сообщения как MMS + Если не удаётся отправить длинные текстовые сообщения или они отправляются в неправильном порядке, вы можете отправить их в виде MMS. Может взиматься дополнительная плата. Автоматически сжимать вложения MMS Синхронизация сообщений Принудительная синхронизация сообщений с собственной базой данных SMS Android @@ -365,12 +376,13 @@ Длинная - 100 КБ - 200 КБ - 300 КБ (рекомендуется) - 600 КБ - 1000 КБ - 2000 КБ + Автоматически + 100 Кб + 200 Кб + 300 Кб + 600 Кб + 1000 Кб + 2000 Кб Без сжатия diff --git a/presentation/src/main/res/values-sk/strings.xml b/presentation/src/main/res/values-sk/strings.xml index d2818ce3b7ccd0d764245fa56256eed5900b3c6a..668b8b4b3a8aec39b8c2672cba3d4564242b8b37 100644 --- a/presentation/src/main/res/values-sk/strings.xml +++ b/presentation/src/main/res/values-sk/strings.xml @@ -30,9 +30,11 @@ Nová správa Preskočiť Pokračovať + Add person Volať Podrobnosti Uložiť do galérie + Share Otvoriť navigačné menu %d vybraných Vymazať @@ -83,6 +85,10 @@ Preposlať Odstrániť + Choose a phone number + %s ∙ Default + Just once + Always %d selected %1$d of %2$d results Poslať ako skupinovú správu @@ -122,6 +128,7 @@ Doručené %s Failed to send. Tap to try again Detaily + Address copied Conversation title Upozornenia Motív @@ -184,6 +191,7 @@ Pure black night mode Čas spustenia Čas ukončenia + Automatic contact colors Veľkosť písma Použiť systémové písmo Automatické emoji @@ -194,6 +202,7 @@ Tlačidlo 2 Tlačidlo 3 Obsah upozornení + Wake screen Vibration Sound None @@ -223,6 +232,8 @@ Remove accents from characters in outgoing SMS messages Mobile numbers only When composing a message, only show mobile numbers + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Auto-compress MMS attachments Sync messages Re-sync your messages with the native Android SMS database @@ -364,13 +375,14 @@ Long + Automatic 100KB 200KB - 300KB (odporúča sa) + 300KB 600KB 1000KB 2000KB - Bez kompresie + No compression V poriadku diff --git a/presentation/src/main/res/values-sl/strings.xml b/presentation/src/main/res/values-sl/strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..eb4a56a048dc6fad5ff416e040fd6a1aa4d218d5 --- /dev/null +++ b/presentation/src/main/res/values-sl/strings.xml @@ -0,0 +1,407 @@ + + + + + Nov pogovor + Sestavi + Bližnjica onemogočena + Arhivirano + Nastavitve + Obvestila + Tema + Išči po prejetih sporočilih… + Vnesi ime ali številko + Preskoči + Nadaljuj + Dodaj osebo + Klic + Podrobnosti + Shrani v galerijo + Deli + Odpri nacigacijski meni + %d izbranih + Počisti + Arhiv + Odstrani iz arhiva + Izbriši + Shrani v imenik + Pripni na vrh + Odpni + Označi kot prebrano + Označi kot neprebrano + Blokiraj + Sinhroniziranje sporočil… + Vi: %s + Osnutek: %s + Rezultati v sporočilih + %d sporočil + Vaši pogovori se bodo pojavili tukaj + Ni rezultatov + Vaši arhivirani pogovori se bodo pojavili tukaj + Začni nov pogovor + Znova vzljubite sporočanje + Napravi QKSMS privzet program za sporočanje + Spremeni + Potrebno dovoljenje + QKSMS potrebuje dovoljenje za pošiljanje in branje SMS sporočil + QKSMS potrebuje dovoljenje za dostop do stikov + Dovoli + Prejeto + Arhiv + Načrtovano + Blokiranje + Več + Nastavitve + Pomagaj & povrate informacije + Povabi prijatelje + Odkleni neverjetne nove funkcije in podpri razvoj + Uživate v QKSMS? + Deli nekaj ljubezni in nas oceni na Google Play! + V REDU! + OPUSTI + Izbriši + + Ali ste prepričani, da želite izbrisati %d pogovor? + Ali ste prepričani, da želite izbrisati %d pogovora? + Ali ste prepričani, da želite izbrisati %d pogovorov? + Ali ste prepričani, da želite izbrisati %d pogovorov? + + + Kopiraj besedilo + Naprej + Izbriši + + Izberi telefonsko številko + %s ∙ Privzeto + Samo enkrat + Vedno + %d izbran + %1$d od %2$d rezultatov + Pošlji kot skupinsko sporočilo + Prejemniki in odgovori bodo vidni vsem + To je začetek vašega pogovora. Povej nekaj lepega! + Kontaktna kartica + Načrtovano za + Izbran čas mora biti v prihodnosti! + Da lahko uporabiš načrtovano sporočanje, moraš odkleniti QKSMS+ + Dodano k načrtovanim sporočilom + Napiši sporočilo… + Kopiraj besedilo + Naprej + Izbriši + Prejšnje + Naslednje + Počisti + Podrobnosti sporočila + Vnesi: %s + Od: %s + Za: %s + Zadeva %s + Prioriteta: %s + Velikost: %s + Poslano: %s + Prejeto: %s + Dostavljeno: %s + Koda napake: %d + Vstavi priponko + Vstavi sliko + Zajemi sliko + Načrtuj sporočilo + Vstavi stik + Napaka pri branju stika + %s izbran, zamenjaj kartico SIM + Pošlji sporočilo + Pošiljanje… + Dostavljeno %s + Napaka pri pošiljanju. Pritisni za ponovni poskus + Podrobnosti + Naslov kopiran + Naslov pogovora + Obvestila + Teme + Arhiv + Ostrani iz arhiva + Blokiraj + Odblokiraj + Izbriši pogovor + Napaka pri nalaganju večpredstavnosti + Shranjeno v galerijo + Varnostna kopija in obnovitev + Ustvarjanje varnostne kopije sporočil + Obnovitev varnostne kopije + Zadnja varnostna kopija + Nalaganje… + Nikoli + Obnovi + Izberi varnostno kopijo + Prosimo odklenite QKSMS+ da omogočite uporabo varnostnih kopij in njihove obnove + Varnostna kopija se izvaja… + Obnavljanje varnostne kopije se izvaja… + Obnovi iz varnotsne kopije + Ali ste prepričani, da želite obnoviti sporočila iz te varnostne kopije? + Prekliči obnovitev varnostne kopije + Sporočila, ki so že bila obnovljena, bodo ostala na vaši napravi + Varnostne kopije + Ni najdenih varnostnih kopij + + %d sporočilo + %d sporočila + %d sporočil + %d sporočila + + Trenutno so za ustvarjanje in obnovo varnostnih kopij podprta le sporočila SMS. MMS podpora in varnostne kopije pridejo kmalu! + Ustvari varnostno kopijo sedaj + Razčlenjevanje varnostne kopije… + %d/%d sporočil + Shranjevanje varnostne kopije… + Sinhroniziranje sporočil… + Končano! + Varnostna kopija in obnovitev + Načrtovano + Samodejno pošlji sporočilo ob nastavljenem točno določenem trenutku + Hej! Kdaj je že tvoj rojstni dan? + Je 23. Decembra + Vse najboljše! Poglej kako dober prijatelj sem, da se spomnim tvojega rojstnega dneva + + Pošiljanje na 23. December + Načrtuj sporočilo + Načrtovano sporočilo + + Pošlji sedaj + Kopiraj besedilo + Izbriši + + Izgled + Glavno + QK Odgovor + Teme + Nočni način + Čisto črni nočni način + Začetni čas + Čas končanja + Samodejne barve stikov + Velikost pisave + Uporabi sistemsko pisavo + Samodejni čustvenček + Obvestila + Tapni za prilagoditev + Dejanja + Gumb 1 + Gumb 2 + Gumb 3 + Predogledi obvestil + Zbudi zaslon + Vibriranje + Zvok + Brez + QK Odgovor + Pojavno obvestilo za nova sporočila + Tapni da opustiš + Tapni izven obvestila, da ga zapreš + Zakasnjeno sporočanje + Dejanja drsenja + Nastavi dejanja drsenja za pogovore + Podrsaj desno + Podrsaj levo + SPREMENI + + Brez + Arhiviraj + Izbriši + Pokliči + Označi kot prebrano + Označi kot neprebrano + + Potrditev dostavitve sporočila + Potrdi, da so bila sporočila dostavljena uspešno + Podpis + Dodaj podpis na konec vaših sporočil + Odstrani narečja + Odstrani narečja iz besedila odhodnih SMS sporočil + Samo telefonske številke + Ko sestavljaš sporočilo, prikaži samo telefonske številke + Pošlji dolga sporočila kot MMS + Če se vaša daljša sporočila ne pošljejo, ali se pošiljajo v napačnem vrstnem redu, jih lahko pošljete kot MMS sporočilo. To lahko povzroči dodatne stroške + Samodejno stisni MMS priponke + Sinhroniziraj sporočila + Znova sinhroniziraj sporočila s sistemsko SMS bazo + Več o QKSMS + Različica %s + Beleženje dnevnika za odpravljanje napak omogočeno + Beleženje dnevnika za odpravljanje napak onemogočeno + Vnesi trajanje (v sekundah) + Blokiranje + Opusti sporočila + Opusti prihajajoča sporočila blokiranih pošiljateljev, namesto da jih skriješ + Blokirani pogovori + Upravljalnik Blokiranja + QKSMS + Vgrajena sposobnost blokiranja v QKSMS + Samodejno razvrstite vaše klice in sporočila v eno priročno mesto! Skupnostni IQ™ omogoča, da preprečite prejemanje sporočil pošiljateljev neželenih sporočil, kateri so skupnosti poznani + Samodejno razvrstite sporočila neželenih številk tako, da uporabiš \"Should I Answer\" aplikacijo + Kopiraj blokirane številke + Nadaljuj z %s in kopiraj obstoječe blokirane številke + Blokirane številke + Vaše blokirane številke se bodo pojavile tukaj + Blokiraj novo številko + Blokiraj besedila od + Telefonska številka + Blokirano + Blokirana sporočila + Blokirana sporočila bodo prikazana tukaj + Blokiraj + Odblokiraj + + Nadaljuj z %s in blokiraj to številko + Nadaljuj z %s in blokiraj ti številki + Nadaljuj z %s in blokiraj te številke + Nadaljuj z %s in blokiraj te številke + + + Nadaljuj z %s in dovoli to številko + Nadaljuj z %s in dovoli ti številki + Nadaljuj z %s in dovoli te številke + Nadaljuj z %s in dovoli te številke + + O aplikaciji + Različica + Razvijalec + Izvirna koda + Dnevnik sprememb + Stik + Licenca + Avtorske pravice + Podpri razvoj, odkleni vse + Že s %s lahko rešiš stradajočega razvijalca + + Doživljenjska nadgradnja za %1$s %2$s + + Odkleni + doniraj za %1$s %2$s + Hvala da podpirate QKSMS! + Zdaj imate dostop do vseh QKSMS+ funkcij + QKSMS+ je brezplačen za F-Droid uporabnike! Če želite podpirati razvoj, so donacije zelo dobrodošle. + Doniraj preko PayPal + Prihaja kmalu + Premium teme + Odkleni čudovite barve tem, ki niso vidne na paleti materialnega dizajna + Auto-čustvenček po meri + Ustvari avto-čustvenček bližnjico po meri + Varnostna kopija sporočila + Samodejno varnostno kopiraj sporočila. Nikoli več ne skrbi za izgubljeno zgodovino, če zamenjaš ali izgubiš telefon + Načrtovana sporočila + Načrtuj, da bodo sporočila poslana ob določenem datumu in času + Zakasnjeno pošiljanje + Počakaj nekaj sekund, preden pošlješ svoje sporočilo + Samodejni temni način + Omogoči temni način, glede na čas dneva + Napredno blokiranje + Blokiraj sporočila, ki vsebujejo ključne besede ali se ujemajo z vzorci + Samodejno-posreduj + Samodejno posreduj sporočila določenih pošiljateljev + Samodejen odgovor + Samodejno odgovori na prihajajoča sporočila s prednastavljenim sporočilom + Več + QKSMS je pod aktivnim razvojem in vaš nakup QKSMS+ bo vseboval vse prihodnje funkcije! + Nalaganje… + Poglej več pogovorov + Označi kot prebrano + Pokliči + Izbriši + Pokaži več + Pokaži manj + Odpri pogovor + Material + HEX + Potrdi + + Brez + Označi kot prebrano + Odgovori + Klic + Izbriši + + Da + Nadaljuj + Prekliči + Izbriši + Shrani + Ustavi + Več + Nastavi + Razveljavi + Kopirano + Arhivirani pogovori + Da uporabiš to, moraš odkleniti QKSMS+ + + Novo sporočilo + %s novi sporočili + %s novih sporočil + %s novih sporočil + + Sporočilo ni poslano + Sporočilo za %s ni bilo poslano + + Sistem + Onemogočeno + Vedno vključen + Samodejno + + + Prikaži ime in sporočilo + Prikaži ime + Skrij vsebino + + + Majhno + Običajno + Veliko + Večje + + + Brez zakasnitve + Kratko + Srednje + Dolgo + + + Automatic + 100KB + 200KB + 300KB + 600KB + 1000KB + 2000KB + No compression + + + V redu + Počakaj trenutek + Na poti + Hvala + Zveni dobro + Kako si? + Se strinjam + Ne + Ljubim te + Oprosti + LOL + To je v redu + + diff --git a/presentation/src/main/res/values-sr/strings.xml b/presentation/src/main/res/values-sr/strings.xml index 14f9b6a8a1c08abfa97385c64505239cefebd834..b42538b2fd5d0592e696464f03be5a8dd12828ee 100644 --- a/presentation/src/main/res/values-sr/strings.xml +++ b/presentation/src/main/res/values-sr/strings.xml @@ -30,9 +30,11 @@ Састављање Skip Continue + Add person Позови Details Save to gallery + Share Open navigation drawer %d selected Clear @@ -80,6 +82,10 @@ Forward Delete + Choose a phone number + %s ∙ Default + Just once + Always %d selected %1$d of %2$d results Send as group message @@ -119,6 +125,7 @@ Delivered %s Failed to send. Tap to try again Details + Address copied Conversation title Notifications Theme @@ -180,6 +187,7 @@ Pure black night mode Start time End time + Automatic contact colors Font size Use system font Automatic emoji @@ -190,6 +198,7 @@ Button 2 Button 3 Notification previews + Wake screen Vibration Sound None @@ -219,6 +228,8 @@ Remove accents from characters in outgoing SMS messages Mobile numbers only When composing a message, only show mobile numbers + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Auto-compress MMS attachments Sync messages Re-sync your messages with the native Android SMS database @@ -357,9 +368,10 @@ Long + Automatic 100KB 200KB - 300KB (Recommended) + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-sv/strings.xml b/presentation/src/main/res/values-sv/strings.xml index 982c76d0466f0f5e014eb0734a75ae8b656fd61a..b869fada8c7173d801f2d3528b3cc7e311539261 100644 --- a/presentation/src/main/res/values-sv/strings.xml +++ b/presentation/src/main/res/values-sv/strings.xml @@ -30,16 +30,18 @@ Ange ett namn eller nummer Hoppa över Fortsätt + Lägg till person Ring Detaljer Spara till galleri + Dela Öppna navigeringspanelen %d vald Rensa Arkiv Avarkivera Radera - Add to contacts + Lägg till i kontakter Fäst på toppen Frigör Markera som läst @@ -83,6 +85,10 @@ Vidarebefordra Radera + Välj ett telefonnummer + %s ∙ Default + Bara en gång + Alltid %d vald %1$d av %2$d resultat Skicka som ett grupp-meddelande @@ -122,6 +128,7 @@ Levererade %s Misslyckades med att skicka. Knacka för att försöka igen Detaljer + Address copied Titel för konversation Aviseringar Tema @@ -172,7 +179,7 @@ Skicka nu Copy text - Delete + Radera Utseende Generellt @@ -182,6 +189,7 @@ Svart nattläge Starttid Sluttid + Automatic contact colors Storlek på typsnitt Använd systemteckensnitt Automatisk uttryckssymbol @@ -192,6 +200,7 @@ Knapp 2 Knapp 3 Notifikations förhandsvisningar + Wake screen Vibration Ljud Ingen @@ -211,16 +220,18 @@ Ta bort Ring Markera som läst - Mark as unread + Markera som oläst Leveransrapporter Bekräfta att meddelanden har skickats - Signature - Add a signature to the end of your messages + Signatur + Lägg till en signatur i slutet av dina meddelanden Ta bort accenter Ta bort accenter från tecken i utåtgående SMS-meddelanden Endast mobilnummer Visa endast mobilnummer när ett meddelanden skrivs + Skicka långa meddelanden som MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Komprimera MMS-bilagor automatiskt Synkronisera meddelanden Synkronisera dina meddelanden med telefonens SMS-databas @@ -229,27 +240,27 @@ Felsökningsloggning aktiverades Felsökningsloggning inaktiverad Fyll i varaktighet (sekunder) - Blocking + Blockerar Drop messages Drop incoming messages from blocked senders instead of hiding them - Blocked conversations - Blocking Manager + Blockerade konversationer + Blockerings-hanterare QKSMS Built-in blocking functionality in QKSMS Automatically filter your calls and messages in one convenient place! Community IQ™ allows you to prevent unwanted messages from community known spammers Filtrera automatiskt meddelanden från oönskade nummer genom att använda appen \"Borde jag svara\" - Copy blocked numbers - Continue to %s and copy over your existing blocked numbers - Blocked numbers - Your blocked numbers will appear here - Block a new number - Block texts from - Phone number - Block - Blocked messages - Your blocked messages will appear here + Kopiera blockerade nummer + Fortsätt till %s och kopiera över dina befintliga blockerade nummer + Blockerade nummer + Dina blockerade nummer kommer att visas här + Blockera ett nytt nummer + Blockera SMS från + Telefonnummer + Blockera + Blockerade meddelanden + Dina blockerade meddelanden visas här Block - Unblock + Avblockera Continue to %s and block this number Continue to %s and block these numbers @@ -315,8 +326,8 @@ Ring Ta bort - Yes - Continue + Ja + Fortsätt Avbryt Radera Spara @@ -334,8 +345,8 @@ Meddelandet till %s misslyckades att skicka System - Disabled - Always on + Inaktiverad + Alltid på Automatic @@ -356,13 +367,14 @@ Lång + Automatisk 100KB - 200KB - 300KB (Rekommenderas) - 600KB + 200kB + 300KB + 600kB 1000KB 2000KB - Ingen kompression + Ingen komprimering Okej diff --git a/presentation/src/main/res/values-th/strings.xml b/presentation/src/main/res/values-th/strings.xml index 078b2033361d187db0e0a952570ca35e6c4b57e2..054100ed96fb2b6891d78619ef375b56dca4ba4e 100644 --- a/presentation/src/main/res/values-th/strings.xml +++ b/presentation/src/main/res/values-th/strings.xml @@ -30,9 +30,11 @@ พิมพ์ชื่อหรือหมายเลข ข้าม ทำต่อ + Add person โทร รายละเอียด บันทึกรูปภาพ + Share เปิดเมนูลัด %d ถูกเลือก ลบออก @@ -78,6 +80,10 @@ ส่งต่อ ลบ + Choose a phone number + %s ∙ Default + Just once + Always %d ถูกเลือก %1$d ใน %2$d ของผลลัพท์ ส่งเป็นข้อความกลุ่ม @@ -117,6 +123,7 @@ %s ถูกส่งแล้ว การส่งล้มเหลว ลองแตะเพื่อส่งอีกครั้ง รายละเอียด + Address copied หัวข้อสนทนา การแจ้งเตือน รูปแบบการแสดงผล @@ -176,6 +183,7 @@ โหมดกลางคืนแบบมืดสนิท เวลาเริ่มต้น เวลาสิ้นสุด + Automatic contact colors ขนาดแบบอักษร ใช้แบบอักษรของระบบ อีโมจิอัตโนมัติ @@ -186,6 +194,7 @@ Button 2 Button 3 ตัวอย่างการแจ้งเตือน + Wake screen การสั่น เสียง ไม่มี @@ -215,6 +224,8 @@ ลบอักษรพิเศษ accents ออกจากข้อความ SMS ที่กำลังจะส่งออก Mobile numbers only When composing a message, only show mobile numbers + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply บีบอัดสิ่งที่แนบมาใน MMS อัตโนมัติ ซิงค์ข้อความ ซิงค์ข้อความของคุณกับฐานข้อมูลของ Android SMS @@ -347,13 +358,14 @@ ยาว + Automatic 100KB 200KB - 300KB (แนะนำ) + 300KB 600KB - 1000 กิโลไบต์ - 2000 กิโลไบต์ - ไม่มีการบีบอัด + 1000KB + 2000KB + No compression ตกลง diff --git a/presentation/src/main/res/values-tl/strings.xml b/presentation/src/main/res/values-tl/strings.xml index 2426c7eaca8e9423a3ac7ef9b3fa328ac607bda2..3c3e47a719e1dd639c4b583908a1e25472e23e76 100644 --- a/presentation/src/main/res/values-tl/strings.xml +++ b/presentation/src/main/res/values-tl/strings.xml @@ -30,9 +30,11 @@ Sumulat Laktawan Magpatuloy + Add person Tawagan Mga detalye Save to gallery + Share Open navigation drawer %d selected Clear @@ -79,6 +81,10 @@ Forward Delete + Choose a phone number + %s ∙ Default + Just once + Always %d selected %1$d of %2$d results Send as group message @@ -118,6 +124,7 @@ Delivered %s Failed to send. Tap to try again Details + Address copied Conversation title Notifications Theme @@ -178,6 +185,7 @@ Pure black night mode Start time End time + Automatic contact colors Font size Use system font Automatic emoji @@ -188,6 +196,7 @@ Button 2 Button 3 Notification previews + Wake screen Vibration Sound None @@ -217,6 +226,8 @@ Remove accents from characters in outgoing SMS messages Mobile numbers only When composing a message, only show mobile numbers + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Auto-compress MMS attachments Sync messages Re-sync your messages with the native Android SMS database @@ -352,9 +363,10 @@ Long + Automatic 100KB 200KB - 300KB (Recommended) + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-tr/strings.xml b/presentation/src/main/res/values-tr/strings.xml index 04af6a62dfe7cace85ba6552559d9597cb29c8dd..7c07d2f285c6fea030ea8dea72a4cf25d8c58c11 100644 --- a/presentation/src/main/res/values-tr/strings.xml +++ b/presentation/src/main/res/values-tr/strings.xml @@ -30,9 +30,11 @@ Bir isme veya numaraya yazın Atla Devam + Kişi ekle Ara Ayrıntılar Galeriye Kaydet + Paylaş Gezinti çubuğunu aç %d Seçili Temizle @@ -83,6 +85,10 @@ Yönlendir Sil + Bir telefon numarası seçin + %s ∙ Varsayılan + Sadece bir kez + Her zaman %d Seçili %1$d / %2$d sonuçlar Grup mesajı gönder @@ -122,6 +128,7 @@ Teslim Edildi %s Gönderilemedi. Yeniden denemek için dokunun Ayrıntılar + Adres kopyalandı Konuşma başlığı Bildirimler Tema @@ -182,6 +189,7 @@ Saf siyah gece modu Başlama zamanı Bitiş Zamanı + Otomatik kişi renkleri Yazı tipi boyutu Sistem yazı tipini kullan Otomatik emoji @@ -192,6 +200,7 @@ Buton 2 Buton 3 Bildirim ön izleme + Uyandırma ekranı Titreşim Ses Hiçbiri @@ -221,6 +230,8 @@ Giden SMS mesajlarından aksanlı karakterleri kaldırın Sadece cep telefonu numaraları Bir mesaj oluştururken, yalnızca cep telefonu numaralarını göster + Uzun mesajları MMS olarak gönder + Uzun metin mesajlarınız gönderilemiyorsa veya yanlış sırada gönderiliyorsa, onları MMS mesajları olarak gönderebilirsiniz. Ek ücretler uygulanabilir Otomatik-MMS ekleri sıkıştır İletileri eşitle İletilerinizi Android SMS veritabanı ile yeniden senkronize edin @@ -358,12 +369,13 @@ BAĞLAM İSTEĞİ Uzun - 100 kilobayt - 200 kilobayt - 300KB (önerilen) - 600 kilobayt - 1000 kilobayt - 2000 kilobayt + Otomatik + 100KB + 200KB + 300KB + 600KB + 1000KB + 2000KB Sıkıştırma yok diff --git a/presentation/src/main/res/values-uk/strings.xml b/presentation/src/main/res/values-uk/strings.xml index 5c932e457e4843e1813aae291dd5869aafc872f2..0dd7b36f5d5f4835b0edfd49d570ee322c4b0008 100644 --- a/presentation/src/main/res/values-uk/strings.xml +++ b/presentation/src/main/res/values-uk/strings.xml @@ -30,9 +30,11 @@ Кому: ім\'я або номер Пропустити Продовжити + Додати контакт Зателефонувати Деталі Зберегти в галереї + Поділитися Відкрити панель навігації Обрано: %d Очистити @@ -83,6 +85,10 @@ Переслати Видалити + Виберіть номер телефону + %s ∙ початково + Один раз + Завжди Обрано: %d %1$d із %2$d результатів Надіслати як групове повідомлення @@ -122,6 +128,7 @@ Доставлено %s Не вдалося надіслати. Торкніться, щоб повторити спробу Деталі + Адреса скопійована Заголовок бесіди Сповіщення Тема @@ -132,7 +139,7 @@ Видалити бесіду Не вдалося завантажити медіа Збережено в галереї - Резервне копіювання та відновлення + Резервне копіювання Резервне копіювання повідомлень Відновити з резервної копії Остання резервна копія @@ -161,7 +168,7 @@ Збереження резервної копії… Синхронізація повідомлень… Готово! - Резервне копіювання та відновлення + Резервне копіювання Заплановані Автоматично надсилайте повідомлення саме в той час, який ви захочете Агов! Коли в тебе день народження? @@ -184,6 +191,7 @@ Повністю чорний нічний режим Час початку Час завершення + Автоматичні кольори контактів Розмір шрифта Використовувати системний шрифт Автоматичні emoji @@ -194,6 +202,7 @@ Кнопка 2 Кнопка 3 Попередній перегляд сповіщень + Екран заблокований Вібро Звук Ні @@ -223,6 +232,8 @@ Видалення акцентів з символів у вихідних SMS-повідомленнях Лише номери мобільних При написанні повідомлення показувати лише мобільні номери + Надсилати довгі повідомлення як MMS + Якщо частини довгого повідомлення не відправляються або роблять це в хибному порядку, ви можете відправити їх як MMS натомість. Оплата згідно тарифів вашого оператора Автоматичне стиснення MMS-вкладень Синхронізація повідомлень Повторна синхронізація ваших повідомлень з власною базою даних SMS Android @@ -266,10 +277,7 @@ Про додаток Версія - Розробник Вихідний код - Журнал змін - Контакт Ліцензія Авторське право Підтримати подальший розвиток, розблокувати все @@ -364,9 +372,10 @@ Довга + Автоматично 100 Кб 200 Кб - 300 Кб (рекомендовано) + 300 Кб 600 Кб 1000 Кб 2000 Кб diff --git a/presentation/src/main/res/values-ur/strings.xml b/presentation/src/main/res/values-ur/strings.xml index 8270ae2c2dc03cd2a5d3040b86916961c5047ab8..ded507f870c177d32d6ee04b66a6b1fb6026801f 100644 --- a/presentation/src/main/res/values-ur/strings.xml +++ b/presentation/src/main/res/values-ur/strings.xml @@ -30,9 +30,11 @@ Type a name or number Skip Continue + Add person Call Details Save to gallery + Share Open navigation drawer %d selected Clear @@ -79,6 +81,10 @@ Forward Delete + Choose a phone number + %s ∙ Default + Just once + Always %d selected %1$d of %2$d results Send as group message @@ -118,6 +124,7 @@ Delivered %s Failed to send. Tap to try again Details + Address copied Conversation title Notifications Theme @@ -178,6 +185,7 @@ Pure black night mode Start time End time + Automatic contact colors Font size Use system font Automatic emoji @@ -188,6 +196,7 @@ Button 2 Button 3 Notification previews + Wake screen Vibration Sound None @@ -217,6 +226,8 @@ Remove accents from characters in outgoing SMS messages Mobile numbers only When composing a message, only show mobile numbers + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Auto-compress MMS attachments Sync messages Re-sync your messages with the native Android SMS database @@ -352,9 +363,10 @@ Long + Automatic 100KB 200KB - 300KB (Recommended) + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-v23/themes.xml b/presentation/src/main/res/values-v23/themes.xml index b789222d59319bc6b0567f360d98d33bfa5bf4ab..c27a31ed10fd58045fda79dfd28735a4c676c276 100644 --- a/presentation/src/main/res/values-v23/themes.xml +++ b/presentation/src/main/res/values-v23/themes.xml @@ -21,7 +21,7 @@ diff --git a/presentation/src/main/res/values-v27/themes.xml b/presentation/src/main/res/values-v27/themes.xml index 6acad9c94216da846b72f886ec2718acf5fffdd8..1a7fd8caedd9f52aef7548ce25196ee0b49b90aa 100644 --- a/presentation/src/main/res/values-v27/themes.xml +++ b/presentation/src/main/res/values-v27/themes.xml @@ -22,7 +22,7 @@ diff --git a/presentation/src/main/res/values-vi/strings.xml b/presentation/src/main/res/values-vi/strings.xml index 6a292a30c1f1b73a3ce7df4f8419abfb9c4ab06b..59b58b46c1e8ebe489e94fcff554c0cdf1acfdae 100644 --- a/presentation/src/main/res/values-vi/strings.xml +++ b/presentation/src/main/res/values-vi/strings.xml @@ -30,9 +30,11 @@ Nhập tên hoặc số điện thoại Bỏ qua Tiếp tục + Thêm người Cuộc gọi Chi tiết Lưu vào bộ sưu tập + Chia sẻ Mở menu điều hướng %d đã chọn Dọn sạch @@ -78,6 +80,10 @@ Chuyển tiếp Xóa + Chọn một số điện thoại + %s ∙ Mặc định + Chỉ lần này + Luôn luôn %d đã chọn %1$d trên %2$d kết quả Gửi theo nhóm tin nhắn @@ -117,6 +123,7 @@ Đã chuyển %s Gửi thất bại. Bấm để thử lại lần nữa Chi tiết + Địa chỉ đã được sao chép Tiêu đề cuộc trò chuyện Thông báo Chủ đề @@ -156,7 +163,7 @@ Sao lưu và khôi phục Hẹn giờ tin nhắn Tự động gửi tin nhắn vào lúc mà bạn muốn - Này, khi nào thì tới sinh nhật bạn ? + Này, khi nào thì đến sinh nhật bạn? Vào ngày 23 tháng 12 đó Chúc mừng sinh nhật! Nhìn xem, tôi đúng là một người bạn tuyệt vời, nhớ ngày sinh nhật của bạn đấy @@ -176,6 +183,7 @@ Chế độ ban đêm tinh khiết Thời gian bắt đầu Thời gian kết thúc + Tự động hiện màu sắc của liên hệ Cỡ chữ Sử dụng font chữ hệ thống Emoji tự động @@ -186,6 +194,7 @@ Nút 2 Nút 3 Xem trước các thông báo + Đánh thức màn hình Rung Âm thanh Không dùng @@ -215,6 +224,8 @@ Bỏ dấu ký tự khi gửi SMS Lọc số di động Khi soạn tin mới, chỉ hiển thị liên hệ có số di động + Gửi đoạn tin nhắn dài như MMS + Nếu đoạn tin nhắn dài bị lỗi khi gửi, hoặc gửi sai thứ tự, bạn có thể gửi chúng như tin nhắn MMS thay thế. Cước phí bổ sung có thể bị áp dụng Tự động nén các tệp đính kèm MMS Đồng bộ tin nhắn Đồng bộ lại tin nhắn với thiết bị @@ -347,12 +358,13 @@ Dài + Tự động 100KB 200KB - 300KB (khuyến nghị) + 300KB 600KB 1000KB - 2M + 2000KB Không nén diff --git a/presentation/src/main/res/values-zh-rCN/strings.xml b/presentation/src/main/res/values-zh-rCN/strings.xml index dfc9f9184896ae0e9ef60a28f8cb08c27ea12524..0f1a1c219546ce9493985531dcef7ac2ca41d186 100644 --- a/presentation/src/main/res/values-zh-rCN/strings.xml +++ b/presentation/src/main/res/values-zh-rCN/strings.xml @@ -30,16 +30,18 @@ 撰写 跳过 继续 + 添加人物 致电 详情 保存到图库 + 分享 打开导航栏 已选择 %d 项 清除 存档 取消存档 删除 - Add to contacts + 添加到通讯录 固定到顶部 取消固定 标记为已读 @@ -47,7 +49,7 @@ 拦截 正在同步信息… 你:%s - Draft: %s + 草稿: %s 信息中的结果 %d 条信息 对话会在这里显示 @@ -78,6 +80,10 @@ 转发 删除 + 选择电话号码 + %s ∙ 默认 + 仅一次 + 始终 已选择%d个项目 已显示%2$d中的%1$d条结果 群发消息 @@ -117,6 +123,7 @@ 已送达 %s 发送失败。点击再试一次 详情 + 地址已复制 对话标题 通知 主题 @@ -165,8 +172,8 @@ 预约短信 现在发送 - Copy text - Delete + 复制文本 + 删除 外观 通用 @@ -176,6 +183,7 @@ 纯黑夜间模式 开始时间 结束时间 + 自动化联系人颜色 字体大小 使用系统字体 自动颜文字 @@ -186,6 +194,7 @@ 按钮2 按钮3 通知预览 + 唤醒屏幕 振动 铃声 @@ -210,11 +219,13 @@ 送达确认 确认短信已成功送达 签名 - Add a signature to the end of your messages + 在您的信息结尾添加签名 删除读音符号 删除短信中字母上的读音符号 仅限手机号码 撰写邮件时, 只显示手机号码 + 将长信息作为彩信发送 + 如果您无法发送较长的信息,或者是以错误的顺序发送,您可以将其作为彩信发送。可能会收取额外的费用 自动压缩彩信附件 同步消息 重新与安卓原生短信数据库进行同步 @@ -223,32 +234,32 @@ 启用了调试日志记录 调试日志记录已禁用 输入时长 (秒) - Blocking - Drop messages - Drop incoming messages from blocked senders instead of hiding them - Blocked conversations - Blocking Manager + 屏蔽 + 丢弃信息 + 丢弃屏蔽的发件人所发送的信息 + 已屏蔽的会话 + 屏蔽管理器 QKSMS - Built-in blocking functionality in QKSMS - Automatically filter your calls and messages in one convenient place! Community IQ™ allows you to prevent unwanted messages from community known spammers + QKSMS中内建的屏蔽功能 + 自动在适当的位置过滤您的来电和信息!Community IQ™允许您阻止社区中已知的垃圾发件人所发送的垃圾信息 使用\"Should I Answer\"应用过滤未知号码的消息 - Copy blocked numbers - Continue to %s and copy over your existing blocked numbers - Blocked numbers - Your blocked numbers will appear here - Block a new number - Block texts from - Phone number - Block - Blocked messages - Your blocked messages will appear here - Block - Unblock + 复制屏蔽的号码 + 继续前往%s并复制到您现有的已屏蔽号码 + 已屏蔽的号码 + 您所屏蔽的号码将会在这里显示 + 屏蔽新的号码 + 屏蔽文本自 + 电话号码 + 屏蔽 + 已屏蔽的信息 + 您所屏蔽的号码将会在这里显示 + 屏蔽 + 取消屏蔽 - Continue to %s and block these numbers + 继续前往%s并屏蔽这个(些)号码 - Continue to %s and allow these numbers + 继续前往%s并允许这个(些)号码 关于 版本 @@ -307,8 +318,8 @@ 呼叫 删除 - Yes - Continue + + 继续 取消 删除 保存 @@ -324,10 +335,10 @@ 消息未发送 给%s的短信发送失败 - System - Disabled - Always on - Automatic + 系统 + 已禁用 + 始终打开 + 自动 显示姓名和消息 @@ -347,9 +358,10 @@ + 自动 100KB 200KB - 300KB (推荐) + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-zh/strings.xml b/presentation/src/main/res/values-zh/strings.xml index b07969c261b483fa2d1f6dd51592c0b03bf8ee61..5419f133eae50435a93e7057b62155eefbcc6412 100644 --- a/presentation/src/main/res/values-zh/strings.xml +++ b/presentation/src/main/res/values-zh/strings.xml @@ -30,9 +30,11 @@ 編輯 跳过 繼續 + 增加傳送人 致電 詳細資訊 保存到圖庫 + 分享 打開導航抽屜 已選擇%d項 清除 @@ -78,6 +80,10 @@ 轉寄 刪除 + 選一個號碼 + %s ∙ 預設 + 就這一次 + 總是如此 已選取 %d 個項目 已顯示 %2$d 條中的 %1$d 條結果 群發訊息 @@ -86,7 +92,6 @@ 連絡人卡片 排程於 所選時間必須是未來時間 - 您必須解鎖 QKSMS+ 以使用排程簡訊 已新增至排程訊息 寫一條訊息... 複製文本 @@ -118,7 +123,8 @@ 已傳遞 %s 發送失敗,點擊重試 詳細資訊 - 會話標題 + Address copied + 對話標題 通知 主題 存檔 @@ -136,7 +142,6 @@ 從未備份 還原 選擇備份 - 請解鎖 QKSMS+ 以使用備份和還原功能 正在進行備份… 正在進行還原… 從備份還原 @@ -178,6 +183,7 @@ 純黑色夜間模式 開始時間 結束時間 + Automatic contact colors 字體大小 使用系統字體 自動 emoji 表情 @@ -188,6 +194,7 @@ 按鈕2 按鈕3 通知預覽 + Wake screen 震動 音效 @@ -217,6 +224,8 @@ 刪除訊息中字母上的讀音符號 僅限手機號碼 撰寫訊息時,只顯示手機號碼 + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply 自動壓縮MMS附件 同步消息 重新與安卓原生訊息數據庫進行同步 @@ -254,10 +263,7 @@ 關於 版本 - 開發者 源代碼 - 更新日誌 - 聯絡資訊 授權 版權 支持開發,解鎖全部功能 @@ -319,7 +325,12 @@ 設置 復原 已複製 - 存檔的會話 + + + + 存檔的對話 + 您必須解鎖QKSMS+才能使用此功能 + %s條新訊息 @@ -349,13 +360,14 @@ + Automatic 100KB 200KB - 300KB (推荐) + 300KB 600KB 1000KB 2000KB - 不压缩 + No compression 好的 diff --git a/presentation/src/main/res/values/colors.xml b/presentation/src/main/res/values/colors.xml index 11813531934dcd5037aad3e96d66f599001ba7ff..503891293a1e68f770e1ebd211aae4f99d695efe 100644 --- a/presentation/src/main/res/values/colors.xml +++ b/presentation/src/main/res/values/colors.xml @@ -24,16 +24,11 @@ #1f000000 #33ffffff - #FFFFFF - #1d262b - #FFFFFF #1d262b #88000000 - #0D000000 - #26000000 - #ECEFF1 + #FFFFFF #192025 #49555F @@ -48,8 +43,9 @@ #67ffffff #80ffffff - #FFFFFF - #242a2f + #ECEFF1 + #11171B + #0F1113 #0C000000 #1AFFFFFF @@ -68,4 +64,262 @@ #00838F + + + #06C9AF + #6DC966 + #F1AF28 + #FF8963 + #FF6969 + #5D99FF + #8899EC + + + + #FFEBEE + #FFCDD2 + #EF9A9A + #E57373 + #EF5350 + #F44336 + #E53935 + #D32F2F + #C62828 + #B71C1C + + + + #FCE4EC + #F8BBD0 + #F48FB1 + #F06292 + #EC407A + #E91E63 + #D81B60 + #C2185B + #AD1457 + #880E4F + + + + #F3E5F5 + #E1BEE7 + #CE93D8 + #BA68C8 + #AB47BC + #9C27B0 + #8E24AA + #7B1FA2 + #6A1B9A + #4A148C + + + + #EDE7F6 + #D1C4E9 + #B39DDB + #9575CD + #7E57C2 + #673AB7 + #5E35B1 + #512DA8 + #4527A0 + #311B92 + + + + #E8EAF6 + #C5CAE9 + #9FA8DA + #7986CB + #5C6BC0 + #3F51B5 + #3949AB + #303F9F + #283593 + #1A237E + + + + #E3F2FD + #BBDEFB + #90CAF9 + #64B5F6 + #42A5F5 + #2196F3 + #1E88E5 + #1976D2 + #1565C0 + #0D47A1 + + + + #E1F5FE + #B3E5FC + #81D4FA + #4FC3F7 + #29B6F6 + #03A9F4 + #039BE5 + #0288D1 + #0277BD + #01579B + + + + #E0F7FA + #B2EBF2 + #80DEEA + #4DD0E1 + #26C6DA + #00BCD4 + #00ACC1 + #0097A7 + #00838F + #006064 + + + + #E0F2F1 + #B2DFDB + #80CBC4 + #4DB6AC + #26A69A + #009688 + #00897B + #00796B + #00695C + #004D40 + + + + #E8F5E9 + #C8E6C9 + #A5D6A7 + #81C784 + #66BB6A + #4CAF50 + #43A047 + #388E3C + #2E7D32 + #1B5E20 + + + + #F1F8E9 + #DCEDC8 + #C5E1A5 + #AED581 + #9CCC65 + #8BC34A + #7CB342 + #689F38 + #558B2F + #33691E + + + + #F9FBE7 + #F0F4C3 + #E6EE9C + #DCE775 + #D4E157 + #CDDC39 + #C0CA33 + #AFB42B + #9E9D24 + #827717 + + + + #FFFDE7 + #FFF9C4 + #FFF59D + #FFF176 + #FFEE58 + #FFEB3B + #FDD835 + #FBC02D + #F9A825 + #F57F17 + + + + #FFF8E1 + #FFECB3 + #FFE082 + #FFD54F + #FFCA28 + #FFC107 + #FFB300 + #FFA000 + #FF8F00 + #FF6F00 + + + + #FFF3E0 + #FFE0B2 + #FFCC80 + #FFB74D + #FFA726 + #FF9800 + #FB8C00 + #F57C00 + #EF6C00 + #E65100 + + + + #FBE9E7 + #FFCCBC + #FFAB91 + #FF8A65 + #FF7043 + #FF5722 + #F4511E + #E64A19 + #D84315 + #BF360C + + + + #EFEBE9 + #D7CCC8 + #BCAAA4 + #A1887F + #8D6E63 + #795548 + #6D4C41 + #5D4037 + #4E342E + #3E2723 + + + + #FAFAFA + #F5F5F5 + #EEEEEE + #E0E0E0 + #BDBDBD + #9E9E9E + #757575 + #616161 + #424242 + #212121 + + + + #ECEFF1 + #CFD8DC + #B0BEC5 + #90A4AE + #78909C + #607D8B + #546E7A + #455A64 + #37474F + #263238 + + diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index d5e5cde6c773ec9bb9dde661a793b1abf40d9039..4a87cdccbc57162e761456ed75d72f3e2e5b6aef 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -34,9 +34,11 @@ Skip Continue + Add person Call Details Save to gallery + Share Open navigation drawer %d selected @@ -89,6 +91,10 @@ Delete + Choose a phone number + %s ∙ Default + Just once + Always %d selected %1$d of %2$d results Send as group message @@ -130,6 +136,7 @@ Failed to send. Tap to try again Details + Address copied Conversation title Notifications Theme @@ -197,6 +204,7 @@ Pure black night mode Start time End time + Automatic contact colors Font size Use system font Automatic emoji @@ -207,6 +215,7 @@ Button 2 Button 3 Notification previews + Wake screen Vibration Sound None @@ -236,6 +245,8 @@ Remove accents from characters in outgoing SMS messages Mobile numbers only When composing a message, only show mobile numbers + Send long messages as MMS + If your longer text messages are failing to send, or sending in the wrong order, you can send them as MMS messages instead. Additional charges may apply Auto-compress MMS attachments Sync messages Re-sync your messages with the native Android SMS database @@ -417,9 +428,10 @@ + Automatic 100KB 200KB - 300KB (Recommended) + 300KB 600KB 1000KB 2000KB @@ -427,6 +439,7 @@ + -1 100 200 300 diff --git a/presentation/src/main/res/values/themes.xml b/presentation/src/main/res/values/themes.xml index 6537493077f63255871aef9881e84c4b1006b0bb..16db691601b33cab977fb2f75490efcb9bf8e0f0 100644 --- a/presentation/src/main/res/values/themes.xml +++ b/presentation/src/main/res/values/themes.xml @@ -20,7 +20,6 @@ - @@ -36,18 +35,17 @@ diff --git a/secrets.tar.enc b/secrets.tar.enc index 35ec7e2a41fd55d371889a7165714576c7fb4506..4291a913328f7cbd5af4f09fa13a338e917eb168 100644 Binary files a/secrets.tar.enc and b/secrets.tar.enc differ