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/.gitlab-ci.yml b/.gitlab-ci.yml index 9f417027e4fe1a5bf6704cf5afa029a45ff2c582..ac554a3429a8094c62036dfde5d6f4733791efb7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: "registry.gitlab.e.foundation/e/os/docker-android-apps-cicd:legacy" +image: "registry.gitlab.e.foundation/e/os/docker-android-apps-cicd:latest" stages: - build diff --git a/README.md b/README.md index a795fb5f7c4ffbd677463939fc2564c0b672e87d..5118ffe58419c7a33cd60b6f779ebe67a94f806c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Message -Message is an open source replacement to the stock messaging app on Android. +Message is an open source replacement to the stock messaging app on Android. Message is forked from [QKSMS](https://github.com/moezbhatti/qksms) ## Authors @@ -14,7 +14,7 @@ in each version of Message. ## Privacy Policy -[Privacy Policy](https://e.foundation/legal-notice-privacy) +[Privacy Policy](https://e.foundation/legal-notice-privacy) [Terms of service](https://e.foundation/legal-notice-privacy) ## License diff --git a/android-smsmms/build.gradle b/android-smsmms/build.gradle index 81dbda501f2e756dd575f87b483030e370f6d5e8..fd0c7c194cc50c7622efcf836f9f457d419a5b34 100644 --- a/android-smsmms/build.gradle +++ b/android-smsmms/build.gradle @@ -25,8 +25,6 @@ android { defaultConfig { minSdkVersion 21 targetSdkVersion 25 - versionCode 1 - versionName "1.0" } lintOptions { 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..06b2a41a803ff655f3e067fb3a82fc7e0c436783 100644 --- a/build.gradle +++ b/build.gradle @@ -7,22 +7,23 @@ buildscript { ext.androidx_emoji_version = '1.0.0' ext.androidx_exifinterface_version = '1.0.0' ext.androidx_testrunner_version = '1.1.0-alpha3' - ext.androidx_viewpager_version = '1.0.0' + ext.androidx_viewpager_version = '1.0.0-beta05' ext.autodispose_version = '1.3.0' + ext.billing_version = '3.0.2' ext.conductor_version = '2.1.5' - ext.coroutines_version = '1.2.2' + ext.coroutines_version = '1.4.3' ext.dagger_version = "2.16" ext.espresso_version = '3.1.0-alpha3' ext.exoplayer_version = "2.8.1" ext.glide_version = "4.8.0" ext.junit_version = '4.12' - ext.kotlin_version = '1.3.50' - ext.lifecycle_version = '2.2.0' - ext.material_version = '1.1.0' + ext.kotlin_version = '1.5.31' + ext.lifecycle_version = '2.1.0' + ext.material_version = '1.0.0' ext.mockito_version = '2.18.3' ext.moshi_version = '1.8.0' ext.okhttp3_version = '4.1.0' - ext.realm_version = '5.8.0' + ext.realm_version = '6.0.2'//'5.8.0' ext.realm_adapters_version = '3.1.0' ext.rxandroid_version = '2.0.1' ext.rxdogtag_version = '0.2.0' @@ -35,18 +36,18 @@ buildscript { ext.abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2] repositories { - maven { url 'https://maven.fabric.io/public' } maven { url 'https://maven.google.com' } jcenter() google() } dependencies { - classpath 'com.android.tools.build:gradle:3.4.2' + classpath 'com.android.tools.build:gradle:7.1.1' 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' classpath "io.realm:realm-gradle-plugin:$realm_version" + classpath 'com.google.firebase:firebase-crashlytics-gradle:2.5.2' + } } @@ -76,3 +77,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/ContextExtensions.kt b/common/src/main/java/com/moez/QKSMS/common/util/extensions/ContextExtensions.kt index 90fc26bbc6c75d60ca3e24cc9b61dd86e1cd60a4..81a7c867c9ebb7d77c1107407f2f9f453f8bc052 100644 --- a/common/src/main/java/com/moez/QKSMS/common/util/extensions/ContextExtensions.kt +++ b/common/src/main/java/com/moez/QKSMS/common/util/extensions/ContextExtensions.kt @@ -18,6 +18,7 @@ */ package com.moez.QKSMS.common.util.extensions +import android.app.job.JobScheduler import android.content.Context import android.content.res.ColorStateList import android.graphics.Color @@ -25,6 +26,7 @@ import android.util.TypedValue import android.widget.Toast import androidx.annotation.StringRes import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService import com.moez.QKSMS.util.tryOrNull fun Context.getColorCompat(colorRes: Int): Int { 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..7f75289f426cb9944fccbabdde93540a0ef54125 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -32,11 +32,10 @@ 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")}\"" - buildConfigField "String", "MIXPANEL_API_KEY", "\"${System.getenv("MIXPANEL_API_KEY")}\"" } productFlavors { @@ -45,7 +44,7 @@ android { } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" } } @@ -66,7 +65,8 @@ dependencies { kapt "com.google.dagger:dagger-android-processor:$dagger_version" kapt "com.google.dagger:dagger-compiler:$dagger_version" compileOnly "javax.annotation:jsr250-api:1.0" - +//Resolve jdk8+ Generation Annotations - javax annotation does not exist + compileOnly 'com.github.pengrad:jdk9-deps:1.0' // rxjava implementation "io.reactivex.rxjava2:rxandroid:$rxandroid_version" implementation "io.reactivex.rxjava2:rxjava:$rxjava_version" @@ -83,7 +83,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" @@ -97,8 +96,9 @@ dependencies { implementation project(":android-smsmms") implementation project(':common') implementation project(':domain') + + withAnalyticsImplementation 'com.android.installreferrer:installreferrer:1.1' withAnalyticsImplementation "com.amplitude:android-sdk:2.16.0" - withAnalyticsImplementation "com.mixpanel.android:mixpanel-android:5.2.1" } repositories { diff --git a/data/src/main/assets/changelog.json b/data/src/main/assets/changelog.json new file mode 100644 index 0000000000000000000000000000000000000000..c4d2e917edc9b15b71d455244cc41e2846ea8ae5 --- /dev/null +++ b/data/src/main/assets/changelog.json @@ -0,0 +1,272 @@ +[ + { + "versionCode": "2218", + "versionName": "3.9.4", + "fixed": [ + "Incorrect handling of shared text that contains a question mark" + ] + }, + { + "versionCode": "2217", + "versionName": "3.9.3", + "improved": [ + "Message sync is now 2x faster" + ] + }, + { + "versionCode": "2216", + "versionName": "3.9.2", + "fixed": [ + "Crash when launching app in 3.9.1" + ] + }, + { + "versionCode": "2215", + "versionName": "3.9.1", + "improved": [ + "Support SMS URIs that only have a body" + ], + "fixed": [ + "Slowness introduced in 3.9.0" + ] + }, + { + "versionCode": "2214", + "versionName": "3.9.0", + "added": [ + "Auto-delete old messages", + "Block/archive messages from notification", + "Swipe to block conversation", + "Share vCard from other apps" + ], + "improved": [ + "Better visibility for conversations with drafts", + "Remember send-as-group preference", + "Shorten \"mark read\" string for notification actions", + "Clarify delayed message durations", + "Trim whitespace when searching", + "Line count limit for contact groups in compose screen", + "Buzz and show warning when SIM is changed", + "Removed Mixpanel" + ], + "fixed": [ + "Incorrect selection state when picking contact phone number", + "Max height for QK dialog", + "Duplicate phone numbers when multiple contacts apps are installed", + "Swipe state gets stuck when marking read conversation as read again", + "Titles and special characters included in contact's initials", + "Subject text not included when sharing", + "Message history not appearing", + "Dismiss notification for failed message not working", + "Incorrect padding for SIM selector", + "Incorrect padding for contact search", + "Incorrect empty state for messasge search", + "Pressing enter creates line break when searching", + "Crash when composing message", + "Scheduled message date picker can be blocked by keyboard", + "Cursor set to wrong position when cancelling message", + "Can't share to group via Simple Contacts", + "\"View more conversations\" button on widget broken" + ] + }, + { + "versionCode": "2213", + "versionName": "3.8.1", + "fixed": [ + "MMS not sending", + "Crash when deleting conversation", + "Crash on startup when upgrading from old version", + "Contact Groups not showing up in some cases" + ] + }, + { + "versionCode": "2212", + "versionName": "3.8.0", + "improved": [ + "Updated translations" + ] + }, + { + "versionCode": "2211", + "versionName": "3.8.0-beta2", + "fixed": [ + "Crash when starting new conversation", + "Swipe actions not working" + ] + }, + { + "versionCode": "2210", + "versionName": "3.8.0-beta1", + "added": [ + "Support for Contact Groups and Starred Contacts", + "Wake screen setting", + "Per-contact colours", + "Automatic colours", + "Option to send long messages as MMS", + "Long-press recipient to copy phone number", + "Copy text from multiple messages", + "Share photos externally" + ], + "improved": [ + "Redesigned compose message screen", + "Redesigned avatars", + "More reliable contact photo loading", + "Custom colours for contacts will appear everywhere, not just inside the conversation", + "More intuitive unicode stripping", + "Make drafts easier to find", + "Faster scrolling performance everywhere", + "Increased max signature length" + ], + "fixed": [ + "Sent attachments are always low quality", + "Not receiving all MMS", + "Contact name/photo might be out of sync with contacts app", + "MMS might appear in the wrong thread", + "Duplicate phone numbers appear when Whatsapp is installed", + "Can't share to multiple people via another app", + "Body not parsed correctly in SMS Uri", + "Notification sounds not working on Android 7", + "Sometimes can't add number to contacts", + "Default SMS \"CHANGE\" button sometimes doesn't work", + "24h timestamps don't work correctly when language set to Japanese", + "Conversation details page slow when there are lots of attachments" + ] + }, + { + "versionCode": "2209", + "versionName": "3.7.10", + "improved": [ + "Include archived conversations in search results", + "Allow manually blocking non-numeric addresses" + ], + "fixed": [ + "MMS appear to have sent, but they haven't", + "New conversations sometimes don't appear" + ] + }, + { + "versionCode": "2208", + "versionName": "3.7.9", + "improved": [ + "Better reliability for sending MMS" + ], + "fixed": [ + "Fixed delayed messages getting stuck \"Sending...\"" + ] + }, + { + "versionCode": "2207", + "versionName": "3.7.8", + "fixed": [ + "Contact not always linked to conversation", + "Wrong colour for disabled preference icons", + "Multiple crashes" + ] + }, + { + "versionCode": "2206", + "versionName": "3.7.7", + "fixed": [ + "New conversations may not appear in inbox" + ] + }, + { + "versionCode": "2204", + "versionName": "3.7.5", + "added": [ + "Added ability to retry sending failed MMS" + ], + "improved": [ + "Show hint for signature setting", + "Keep widget more up-to-date", + "Improve MMS support for Dual-SIM phones", + "Display sent time for incoming MMS", + "Improve MMS sending reliability" + ], + "fixed": [ + "Contacts sometimes not linked to conversation correctly", + "Sometimes can't save multiple attachments to phone" + ] + }, + { + "versionCode": "2203", + "versionName": "3.7.4", + "added": [ + "Support for Android Q Roles" + ], + "improved": [ + "Use theme color for text cursor", + "Better matching between phone number and conversation" + ], + "fixed": [ + "Messages often stuck \"Sending...\"", + "Short codes won't show up as valid number when composing new message" + ] + }, + { + "versionCode": "2202", + "versionName": "3.7.3", + "added": [ + "You can now view attachments of any filetype" + ], + "improved": [ + "Improve search for contacts with accents in their names", + "Custom labels for phone numbers will show the custom text", + "Include MMS in search results", + "Made it easier to access blocked numbers list", + "Added option to add number to contacts from main conversation list", + "Conversations with drafts will be indicated appropriately in conversation list", + "You can now copy text from a scheduled message", + "Show blocking reason for numbers blocked via Call Control" + ], + "fixed": [ + "Can't open vCard attachments", + "Phone number gets blocked when it shouldn't", + "Icon gets stretched when swiping conversation", + "Can't forward text in MMS message" + ] + }, + { + "versionCode": "2201", + "versionName": "3.7.2", + "fixed": [ + "Various crashes from 3.7.1" + ] + }, + { + "versionCode": "2200", + "versionName": "3.7.1", + "fixed": [ + "Various crashes from 3.7.0" + ] + }, + { + "versionCode": "2198", + "versionName": "3.6.8", + "fixed": [ + "Messages can't be sent or received on older devices", + "Norweigan translations not working" + ] + }, + { + "versionCode": "2197", + "versionName": "3.6.7", + "added": [ + "Signature support", + "System dark mode support for Android Q" + ], + "improved": [ + "You'll be asked for storage permission before trying to save a photo if necessary", + "Smaller download size", + "Load high resolution contact photos" + ], + "fixed": [ + "Contact photos never update", + "Can't create new conversation on Huawei devices", + "Can't receive MMS on Xiaomi devices", + "Visual glitch on compose bar in night mode", + "Theme doesn't update in preview after going back to settings", + "Search hint gets cut off after rotating screen" + ] + } +] diff --git a/data/src/main/java/com/moez/QKSMS/blocking/BlockingManager.kt b/data/src/main/java/com/moez/QKSMS/blocking/BlockingManager.kt index 23d9e758600ae1c525df1a3390367fb74402289e..a9f4114d7610f5b3f17f8da5f84140e59640f61e 100644 --- a/data/src/main/java/com/moez/QKSMS/blocking/BlockingManager.kt +++ b/data/src/main/java/com/moez/QKSMS/blocking/BlockingManager.kt @@ -12,6 +12,7 @@ import javax.inject.Singleton @Singleton class BlockingManager @Inject constructor( private val prefs: Preferences, + private val callBlockerBlockingClient: CallBlockerBlockingClient, private val callControlBlockingClient: CallControlBlockingClient, private val qksmsBlockingClient: QksmsBlockingClient, private val shouldIAnswerBlockingClient: ShouldIAnswerBlockingClient @@ -19,6 +20,7 @@ class BlockingManager @Inject constructor( private val client: BlockingClient get() = when (prefs.blockingManager.get()) { + Preferences.BLOCKING_MANAGER_CB -> callBlockerBlockingClient Preferences.BLOCKING_MANAGER_SIA -> shouldIAnswerBlockingClient Preferences.BLOCKING_MANAGER_CC -> callControlBlockingClient else -> qksmsBlockingClient @@ -28,7 +30,9 @@ class BlockingManager @Inject constructor( override fun getClientCapability(): BlockingClient.Capability = client.getClientCapability() - override fun getAction(address: String): Single = client.getAction(address) + override fun shouldBlock(address: String): Single = client.shouldBlock(address) + + override fun isBlacklisted(address: String): Single = client.isBlacklisted(address) override fun block(addresses: List): Completable = client.block(addresses) diff --git a/data/src/main/java/com/moez/QKSMS/blocking/CallBlockerBlockingClient.kt b/data/src/main/java/com/moez/QKSMS/blocking/CallBlockerBlockingClient.kt new file mode 100644 index 0000000000000000000000000000000000000000..965658a991a0ea1aa8472bee27aeb337e974002c --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/blocking/CallBlockerBlockingClient.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2020 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.blocking + +import android.content.Context +import android.content.Intent +import android.database.Cursor +import android.net.Uri +import androidx.core.database.getStringOrNull +import com.moez.QKSMS.common.util.extensions.isInstalled +import com.moez.QKSMS.extensions.map +import io.reactivex.Completable +import io.reactivex.Single +import timber.log.Timber +import java.util.* +import javax.inject.Inject + +class CallBlockerBlockingClient @Inject constructor( + private val context: Context +) : BlockingClient { + + class LookupResult(cursor: Cursor) { + val blockReason: String? = cursor.getStringOrNull(0) + } + + override fun isAvailable(): Boolean = context.isInstalled("com.cuiet.blockCalls") + + override fun getClientCapability() = BlockingClient.Capability.BLOCK_WITH_PERMISSION + + override fun shouldBlock(address: String): Single = lookup(address, "incomingNumber") + + override fun isBlacklisted(address: String): Single = lookup(address, "blacklistLookup") + + private fun lookup(address: String, reason: String): Single = Single.fromCallable { + val uri = Uri.parse("content://com.cuiet.blockCalls.ContProvBlockCalls/lookup/is.blocked.lookup") + return@fromCallable try { + val blockReason = context.contentResolver.query(uri, arrayOf("result"), reason, arrayOf(address), null) + ?.use { cursor -> cursor.map(::LookupResult) } + ?.find { result -> result.blockReason != null } + ?.blockReason + + when (blockReason) { + "true" -> BlockingClient.Action.Block() + else -> BlockingClient.Action.Unblock + } + } catch (e: Exception) { + Timber.w(e) + BlockingClient.Action.DoNothing + } + } + + override fun block(addresses: List): Completable = Completable.fromCallable { + val arrayList = ArrayList() + arrayList.addAll(addresses) + val intent = Intent("com.cuiet.blockCalls.ADD_NUMBERS") + intent.putStringArrayListExtra("addresses", arrayList) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + + override fun unblock(addresses: List): Completable = Completable.fromCallable { + val arrayList = ArrayList() + arrayList.addAll(addresses) + val intent = Intent("com.cuiet.blockCalls.REMOVE_NUMBERS") + intent.putStringArrayListExtra("addresses", arrayList) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + + override fun openSettings() { + val intent = Intent("com.cuiet.blockCalls.OPEN_SETTINGS") + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } +} diff --git a/data/src/main/java/com/moez/QKSMS/blocking/CallControlBlockingClient.kt b/data/src/main/java/com/moez/QKSMS/blocking/CallControlBlockingClient.kt index ada74816c73cf167bf01ea945af384459270e9a4..a22632fea303bc86708cef9e3d64538c10f9195b 100644 --- a/data/src/main/java/com/moez/QKSMS/blocking/CallControlBlockingClient.kt +++ b/data/src/main/java/com/moez/QKSMS/blocking/CallControlBlockingClient.kt @@ -26,9 +26,9 @@ import androidx.core.database.getStringOrNull import com.callcontrol.datashare.CallControl import com.moez.QKSMS.common.util.extensions.isInstalled import com.moez.QKSMS.extensions.map -import com.moez.QKSMS.util.tryOrNull import io.reactivex.Completable import io.reactivex.Single +import timber.log.Timber import javax.inject.Inject class CallControlBlockingClient @Inject constructor( @@ -48,17 +48,23 @@ class CallControlBlockingClient @Inject constructor( override fun getClientCapability() = BlockingClient.Capability.BLOCK_WITH_PERMISSION - override fun getAction(address: String): Single = Single.fromCallable { + override fun shouldBlock(address: String): Single = isBlacklisted(address) + + override fun isBlacklisted(address: String): Single = Single.fromCallable { val uri = Uri.withAppendedPath(CallControl.LOOKUP_TEXT_URI, address) - val blockReason = tryOrNull { - context.contentResolver.query(uri, projection, null, null, null) // Query URI + return@fromCallable try { + val blockReason = context.contentResolver.query(uri, projection, null, null, null) // Query URI ?.use { cursor -> cursor.map(::LookupResult) } // Map to Result object ?.find { result -> result.blockReason != null } // Check if any are blocked - }?.blockReason // If none are blocked or we errored at some point, return false + ?.blockReason // If none are blocked or we errored at some point, return false - when (blockReason) { - null -> BlockingClient.Action.Unblock - else -> BlockingClient.Action.Block(blockReason) + when (blockReason) { + null -> BlockingClient.Action.Unblock + else -> BlockingClient.Action.Block(blockReason) + } + } catch (e: Exception) { + Timber.w(e) + BlockingClient.Action.DoNothing } } diff --git a/data/src/main/java/com/moez/QKSMS/blocking/QksmsBlockingClient.kt b/data/src/main/java/com/moez/QKSMS/blocking/QksmsBlockingClient.kt index 33be2b182eebe92f093d5df227a974d650984576..6ea3f9b944c2c9ec45747d3717f32706026178ee 100644 --- a/data/src/main/java/com/moez/QKSMS/blocking/QksmsBlockingClient.kt +++ b/data/src/main/java/com/moez/QKSMS/blocking/QksmsBlockingClient.kt @@ -31,7 +31,9 @@ class QksmsBlockingClient @Inject constructor( override fun getClientCapability() = BlockingClient.Capability.BLOCK_WITHOUT_PERMISSION - override fun getAction(address: String): Single = Single.fromCallable { + override fun shouldBlock(address: String): Single = isBlacklisted(address) + + override fun isBlacklisted(address: String): Single = Single.fromCallable { when (blockingRepo.isBlocked(address)) { true -> BlockingClient.Action.Block() false -> BlockingClient.Action.Unblock diff --git a/data/src/main/java/com/moez/QKSMS/blocking/ShouldIAnswerBlockingClient.kt b/data/src/main/java/com/moez/QKSMS/blocking/ShouldIAnswerBlockingClient.kt index bfbc65a0cc5600f61e34e9b826f01239cf87aace..84c927e5d498b53805a32682072498b3df1036dc 100644 --- a/data/src/main/java/com/moez/QKSMS/blocking/ShouldIAnswerBlockingClient.kt +++ b/data/src/main/java/com/moez/QKSMS/blocking/ShouldIAnswerBlockingClient.kt @@ -55,7 +55,9 @@ class ShouldIAnswerBlockingClient @Inject constructor( override fun getClientCapability() = BlockingClient.Capability.CANT_BLOCK - override fun getAction(address: String): Single { + override fun shouldBlock(address: String): Single = isBlacklisted(address) + + override fun isBlacklisted(address: String): Single { return Binder(context, address).isBlocked() .map { blocked -> when (blocked) { 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/ChangelogManagerImpl.kt b/data/src/main/java/com/moez/QKSMS/manager/ChangelogManagerImpl.kt index 3b09f6d253342f0611735b461464cbd89f43a84f..6d414de04c5cec5d1bf6b1631f7137d6b2726374 100644 --- a/data/src/main/java/com/moez/QKSMS/manager/ChangelogManagerImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/manager/ChangelogManagerImpl.kt @@ -24,14 +24,14 @@ import com.moez.QKSMS.util.Preferences import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.squareup.moshi.Moshi +import com.squareup.moshi.Types import io.reactivex.Single import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.Call import okhttp3.Callback -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.OkHttpClient -import okhttp3.Request import okhttp3.Response import java.io.IOException import javax.inject.Inject @@ -44,54 +44,24 @@ class ChangelogManagerImpl @Inject constructor( override fun didUpdate(): Boolean = prefs.changelogVersion.get() != context.versionCode - override fun getChangelog(): Single { - val url = "https://firestore.googleapis.com/v1/projects/qksms-app/databases/(default)/documents/changelog" - val request = url.toHttpUrlOrNull()?.let { Request.Builder().url(it).build() } - val call = request?.let { OkHttpClient().newCall(it) } - val adapter = moshi.adapter(ChangelogResponse::class.java) + override suspend fun getChangelog(): ChangelogManager.CumulativeChangelog { + val listType = Types.newParameterizedType(List::class.java, Changeset::class.java) + val adapter = moshi.adapter>(listType) - return Single - .create { emitter -> - call?.enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - if (!emitter.isDisposed) { - emitter.onSuccess(response) - } - } - - override fun onFailure(call: Call, e: IOException) { - if (!emitter.isDisposed) { - emitter.onError(e) - } - } - }) - emitter.setCancellable { - call?.cancel() - } - } - .map { response -> response.body?.string()?.let(adapter::fromJson) } - .map { response -> - response.documents - .sortedBy { document -> document.fields.versionCode.value } - .filter { document -> - val range = (prefs.changelogVersion.get() + 1)..context.versionCode - document.fields.versionCode.value.toInt() in range - } - } - .map { documents -> - val added = documents.fold(listOf()) { acc, document -> - acc + document.fields.added?.value?.values?.map { value -> value.value }.orEmpty() - } - val improved = documents.fold(listOf()) { acc, document -> - acc + document.fields.improved?.value?.values?.map { value -> value.value }.orEmpty() + return withContext(Dispatchers.IO) { + val changelogs = context.assets.open("changelog.json").bufferedReader().use { it.readText() } + .let(adapter::fromJson) + .orEmpty() + .sortedBy { changelog -> changelog.versionCode } + .filter { changelog -> + changelog.versionCode in prefs.changelogVersion.get().inc()..context.versionCode } - val fixed = documents.fold(listOf()) { acc, document -> - acc + document.fields.fixed?.value?.values?.map { value -> value.value }.orEmpty() - } - ChangelogManager.Changelog(added, improved, fixed) - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) + + ChangelogManager.CumulativeChangelog( + added = changelogs.fold(listOf()) { acc, changelog -> acc + changelog.added.orEmpty()}, + improved = changelogs.fold(listOf()) { acc, changelog -> acc + changelog.improved.orEmpty()}, + fixed = changelogs.fold(listOf()) { acc, changelog -> acc + changelog.fixed.orEmpty()}) + } } override fun markChangelogSeen() { @@ -99,42 +69,12 @@ class ChangelogManagerImpl @Inject constructor( } @JsonClass(generateAdapter = true) - data class ChangelogResponse( - @Json(name = "documents") val documents: List - ) - - @JsonClass(generateAdapter = true) - data class Document( - @Json(name = "fields") val fields: Changelog - ) - - @JsonClass(generateAdapter = true) - data class Changelog( - @Json(name = "added") val added: ArrayField?, - @Json(name = "improved") val improved: ArrayField?, - @Json(name = "fixed") val fixed: ArrayField?, - @Json(name = "versionName") val versionName: StringField, - @Json(name = "versionCode") val versionCode: IntegerField - ) - - @JsonClass(generateAdapter = true) - data class ArrayField( - @Json(name = "arrayValue") val value: ArrayValues - ) - - @JsonClass(generateAdapter = true) - data class ArrayValues( - @Json(name = "values") val values: List - ) - - @JsonClass(generateAdapter = true) - data class StringField( - @Json(name = "stringValue") val value: String - ) - - @JsonClass(generateAdapter = true) - data class IntegerField( - @Json(name = "integerValue") val value: String + data class Changeset( + @Json(name = "added") val added: List?, + @Json(name = "improved") val improved: List?, + @Json(name = "fixed") val fixed: List?, + @Json(name = "versionName") val versionName: String, + @Json(name = "versionCode") val versionCode: Int ) } diff --git a/data/src/main/java/com/moez/QKSMS/manager/PermissionManagerImpl.kt b/data/src/main/java/com/moez/QKSMS/manager/PermissionManagerImpl.kt index f910428c93097f18a1591c12cf3d88fc1a21085f..93620b69d2ce42978a4947e8308f4e7b7e896220 100644 --- a/data/src/main/java/com/moez/QKSMS/manager/PermissionManagerImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/manager/PermissionManagerImpl.kt @@ -21,7 +21,7 @@ package com.moez.QKSMS.manager import android.Manifest import android.app.role.RoleManager import android.content.Context -import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.content.pm.PackageManager import android.os.Build import android.provider.Telephony import androidx.core.content.ContextCompat @@ -38,28 +38,31 @@ class PermissionManagerImpl @Inject constructor(private val context: Context) : } override fun hasReadSms(): Boolean { - return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) == PERMISSION_GRANTED + return hasPermission(Manifest.permission.READ_SMS) } override fun hasSendSms(): Boolean { - return ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) == PERMISSION_GRANTED + return hasPermission(Manifest.permission.SEND_SMS) } override fun hasContacts(): Boolean { - return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PERMISSION_GRANTED + return hasPermission(Manifest.permission.READ_CONTACTS) } override fun hasPhone(): Boolean { - return ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PERMISSION_GRANTED + return hasPermission(Manifest.permission.READ_PHONE_STATE) } override fun hasCalling(): Boolean { - return ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PERMISSION_GRANTED + return hasPermission(Manifest.permission.CALL_PHONE) } override fun hasStorage(): Boolean { - return ContextCompat.checkSelfPermission(context, - Manifest.permission.WRITE_EXTERNAL_STORAGE) == PERMISSION_GRANTED + return hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) } -} \ No newline at end of file + private fun hasPermission(permission: String): Boolean { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + +} diff --git a/data/src/main/java/com/moez/QKSMS/manager/RatingManager.kt b/data/src/main/java/com/moez/QKSMS/manager/RatingManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..0bf948912be79eaad245b0d9bdf7f5475b1f6bdd --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/manager/RatingManager.kt @@ -0,0 +1,37 @@ + +/* + * 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.manager + +import io.reactivex.Observable + +interface RatingManager { + + val shouldShowRating: Observable + + /** + * Whether or not we should show the rating UI should depend on the number of sessions + */ + fun addSession() + + fun rate() + + fun dismiss() + +} \ No newline at end of file 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/CursorToMessageImpl.kt b/data/src/main/java/com/moez/QKSMS/mapper/CursorToMessageImpl.kt index 08bfb8f37837844120c57bcb01efa55bba950279..03cbe2503053afe058de43c5988855875e15b917 100644 --- a/data/src/main/java/com/moez/QKSMS/mapper/CursorToMessageImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/mapper/CursorToMessageImpl.kt @@ -124,8 +124,6 @@ class CursorToMessageImpl @Inject constructor( ?.let { EncodedStringValue(subjectCharset, it).string } ?: "" textContentType = "" attachmentType = Message.AttachmentType.NOT_LOADED - - parts.addAll(cursorToPart.getPartsCursor(contentId)?.map { cursorToPart.map(it) } ?: listOf()) } } } diff --git a/data/src/main/java/com/moez/QKSMS/mapper/CursorToPartImpl.kt b/data/src/main/java/com/moez/QKSMS/mapper/CursorToPartImpl.kt index 7624f4cbf57be4bf181f295b77c36acc633b7be6..23a2c6a95e057a311713988647918ac6e7035e41 100644 --- a/data/src/main/java/com/moez/QKSMS/mapper/CursorToPartImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/mapper/CursorToPartImpl.kt @@ -35,6 +35,7 @@ class CursorToPartImpl @Inject constructor(private val context: Context) : Curso override fun map(from: Cursor) = MmsPart().apply { id = from.getLong(from.getColumnIndexOrThrow(Telephony.Mms.Part._ID)) + messageId = from.getLong(from.getColumnIndexOrThrow(Telephony.Mms.Part.MSG_ID)) type = from.getStringOrNull(from.getColumnIndexOrThrow(Telephony.Mms.Part.CONTENT_TYPE)) ?: "*/*" seq = from.getIntOrNull(from.getColumnIndexOrThrow(Telephony.Mms.Part.SEQ)) ?: -1 name = from.getStringOrNull(from.getColumnIndexOrThrow(Telephony.Mms.Part.NAME)) @@ -43,9 +44,12 @@ class CursorToPartImpl @Inject constructor(private val context: Context) : Curso text = from.getStringOrNull(from.getColumnIndexOrThrow(Telephony.Mms.Part.TEXT)) } - override fun getPartsCursor(messageId: Long): Cursor? { - return context.contentResolver.query(CONTENT_URI, null, - "${Telephony.Mms.Part.MSG_ID} = ?", arrayOf(messageId.toString()), null) + override fun getPartsCursor(messageId: Long?): Cursor? { + return when (messageId) { + null -> context.contentResolver.query(CONTENT_URI, null, null, null, null) + else -> context.contentResolver.query(CONTENT_URI, null, + "${Telephony.Mms.Part.MSG_ID} = ?", arrayOf(messageId.toString()), null) + } } -} \ 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/mapper/RatingManagerImpl.kt b/data/src/main/java/com/moez/QKSMS/mapper/RatingManagerImpl.kt new file mode 100644 index 0000000000000000000000000000000000000000..7b13074faede564da608b455b5028e16ea846397 --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/mapper/RatingManagerImpl.kt @@ -0,0 +1,62 @@ +/* + * 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.mapper + +import com.f2prateek.rx.preferences2.RxSharedPreferences +import com.moez.QKSMS.manager.AnalyticsManager +import com.moez.QKSMS.manager.RatingManager +import io.reactivex.rxkotlin.Observables +import javax.inject.Inject + +class RatingManagerImpl @Inject constructor( + rxPrefs: RxSharedPreferences, + private val analyticsManager: AnalyticsManager +) : RatingManager { + + companion object { + private const val RATING_THRESHOLD = 10 + } + + private val sessions = rxPrefs.getInteger("sessions", 0) + private val rated = rxPrefs.getBoolean("rated", false) + private val dismissed = rxPrefs.getBoolean("dismissed", false) + + override val shouldShowRating = Observables.combineLatest( + sessions.asObservable(), + rated.asObservable(), + dismissed.asObservable() + ) { sessions, rated, dismissed -> + sessions > RATING_THRESHOLD && !rated && !dismissed + } + + override fun addSession() { + sessions.set(sessions.get() + 1) + } + + override fun rate() { + analyticsManager.track("Clicked Rate") + rated.set(true) + } + + override fun dismiss() { + analyticsManager.track("Clicked Rate (Dismiss)") + dismissed.set(true) + } + +} \ No newline at end of file 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..a006f83b40acab74852bc088090dabbd36c1c8c9 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 = 11 } + @SuppressLint("ApplySharedPref") override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { var version = oldVersion @@ -118,6 +129,111 @@ 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++ + } + + if (version == 9L) { + val migrateNotificationAction = { pref: Int -> + when (pref) { + 1 -> Preferences.NOTIFICATION_ACTION_READ + 2 -> Preferences.NOTIFICATION_ACTION_REPLY + 3 -> Preferences.NOTIFICATION_ACTION_CALL + 4 -> Preferences.NOTIFICATION_ACTION_DELETE + else -> pref + } + } + + val migrateSwipeAction = { pref: Int -> + when (pref) { + 2 -> Preferences.SWIPE_ACTION_DELETE + 3 -> Preferences.SWIPE_ACTION_CALL + 4 -> Preferences.SWIPE_ACTION_READ + 5 -> Preferences.SWIPE_ACTION_UNREAD + else -> pref + } + } + + if (prefs.notifAction1.isSet) prefs.notifAction1.set(migrateNotificationAction(prefs.notifAction1.get())) + if (prefs.notifAction2.isSet) prefs.notifAction2.set(migrateNotificationAction(prefs.notifAction2.get())) + if (prefs.notifAction3.isSet) prefs.notifAction3.set(migrateNotificationAction(prefs.notifAction3.get())) + if (prefs.swipeLeft.isSet) prefs.swipeLeft.set(migrateSwipeAction(prefs.swipeLeft.get())) + if (prefs.swipeRight.isSet) prefs.swipeRight.set(migrateSwipeAction(prefs.swipeRight.get())) + + version++ + } + + if (version == 10L) { + realm.schema.get("MmsPart") + ?.addField("messageId", Long::class.java, FieldAttribute.INDEXED, FieldAttribute.REQUIRED) + ?.transform { part -> + val messageId = part.linkingObjects("Message", "parts").firstOrNull()?.getLong("contentId") ?: 0 + part.setLong("messageId", messageId) + } + + version++ + } + check(version >= newVersion) { "Migration missing from v$oldVersion to v$newVersion" } } diff --git a/data/src/main/java/com/moez/QKSMS/receiver/BlockThreadReceiver.kt b/data/src/main/java/com/moez/QKSMS/receiver/BlockThreadReceiver.kt new file mode 100644 index 0000000000000000000000000000000000000000..68143ed8d03547f32543459ca38c2d42a6ed467f --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/receiver/BlockThreadReceiver.kt @@ -0,0 +1,52 @@ +/* + * 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.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.moez.QKSMS.blocking.BlockingClient +import com.moez.QKSMS.interactor.MarkBlocked +import com.moez.QKSMS.repository.ConversationRepository +import com.moez.QKSMS.util.Preferences +import dagger.android.AndroidInjection +import javax.inject.Inject + +class BlockThreadReceiver : BroadcastReceiver() { + + @Inject lateinit var blockingClient: BlockingClient + @Inject lateinit var conversationRepo: ConversationRepository + @Inject lateinit var markBlocked: MarkBlocked + @Inject lateinit var prefs: Preferences + + override fun onReceive(context: Context, intent: Intent) { + AndroidInjection.inject(this, context) + + val pendingResult = goAsync() + val threadId = intent.getLongExtra("threadId", 0) + val conversation = conversationRepo.getConversation(threadId)!! + val blockingManager = prefs.blockingManager.get() + + blockingClient + .block(conversation.recipients.map { it.address }) + .andThen(markBlocked.buildObservable(MarkBlocked.Params(listOf(threadId), blockingManager, null))) + .subscribe { pendingResult.finish() } + } + +} diff --git a/data/src/main/java/com/moez/QKSMS/receiver/MarkArchivedReceiver.kt b/data/src/main/java/com/moez/QKSMS/receiver/MarkArchivedReceiver.kt new file mode 100644 index 0000000000000000000000000000000000000000..55fdf23241263f0d2ae5f3f37e758990b04ed9f2 --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/receiver/MarkArchivedReceiver.kt @@ -0,0 +1,40 @@ +/* + * 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.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.moez.QKSMS.interactor.MarkArchived +import dagger.android.AndroidInjection +import javax.inject.Inject + +class MarkArchivedReceiver : BroadcastReceiver() { + + @Inject lateinit var markArchived: MarkArchived + + override fun onReceive(context: Context, intent: Intent) { + AndroidInjection.inject(this, context) + + val pendingResult = goAsync() + val threadId = intent.getLongExtra("threadId", 0) + markArchived.execute(listOf(threadId)) { pendingResult.finish() } + } + +} \ No newline at end of file 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..a5438d34627f43ccfb61c73de7993fd629e88007 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 @@ -62,14 +66,17 @@ import java.io.File import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException +import java.util.* +import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +import kotlin.collections.ArrayList +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 +117,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 +265,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 +307,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 +334,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 +478,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 +509,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 +520,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 +567,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 +577,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 +608,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 +631,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 +650,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 +671,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 +694,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 +702,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) } } @@ -639,4 +727,27 @@ class MessageRepositoryImpl @Inject constructor( } } -} \ No newline at end of file + override fun getOldMessageCounts(maxAgeDays: Int): Map { + return Realm.getDefaultInstance().use { realm -> + realm.where(Message::class.java) + .lessThan("date", now() - TimeUnit.DAYS.toMillis(maxAgeDays.toLong())) + .findAll() + .groupingBy { message -> message.threadId } + .eachCount() + } + } + + override fun deleteOldMessages(maxAgeDays: Int) { + return Realm.getDefaultInstance().use { realm -> + val messages = realm.where(Message::class.java) + .lessThan("date", now() - TimeUnit.DAYS.toMillis(maxAgeDays.toLong())) + .findAll() + + val uris = messages.map { it.getUri() } + + realm.executeTransaction { messages.deleteAllFromRealm() } + + uris.forEach { uri -> context.contentResolver.delete(uri, 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..1990adf1044cb10f8ffad109562c80d7dedf529d 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/SyncRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/SyncRepositoryImpl.kt @@ -23,17 +23,23 @@ import android.content.ContentUris import android.net.Uri import android.provider.Telephony import com.f2prateek.rx.preferences2.RxSharedPreferences +import com.moez.QKSMS.extensions.forEach 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.CursorToPart 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 @@ -41,6 +47,7 @@ import com.moez.QKSMS.util.tryOrNull import io.reactivex.subjects.BehaviorSubject import io.reactivex.subjects.Subject import io.realm.Realm +import io.realm.RealmList import io.realm.Sort import javax.inject.Inject import javax.inject.Singleton @@ -51,15 +58,18 @@ class SyncRepositoryImpl @Inject constructor( private val conversationRepo: ConversationRepository, private val cursorToConversation: CursorToConversation, private val cursorToMessage: CursorToMessage, + private val cursorToPart: CursorToPart, 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 +99,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) @@ -96,25 +107,48 @@ class SyncRepositoryImpl @Inject constructor( keys.reset() + val partsCursor = cursorToPart.getPartsCursor() val messageCursor = cursorToMessage.getMessagesCursor() val conversationCursor = cursorToConversation.getConversationsCursor() val recipientCursor = cursorToRecipient.getRecipientCursor() - val max = (messageCursor?.count ?: 0) + + val max = (partsCursor?.count ?: 0) + + (messageCursor?.count ?: 0) + (conversationCursor?.count ?: 0) + (recipientCursor?.count ?: 0) var progress = 0 + // Sync message parts + partsCursor?.use { + partsCursor.forEach { + tryOrNull { + progress++ + val part = cursorToPart.map(partsCursor) + realm.insertOrUpdate(part) + } + } + } + // Sync messages messageCursor?.use { val messageColumns = CursorToMessage.MessageColumns(messageCursor) - val messages = messageCursor.map { cursor -> - progress++ - syncProgress.onNext(SyncRepository.SyncProgress.Running(max, progress, false)) - cursorToMessage.map(Pair(cursor, messageColumns)) + messageCursor.forEach { cursor -> + tryOrNull { + progress++ + syncProgress.onNext(SyncRepository.SyncProgress.Running(max, progress, false)) + val message = cursorToMessage.map(Pair(cursor, messageColumns)).apply { + if (isMms()) { + parts = RealmList().apply { + addAll(realm.where(MmsPart::class.java) + .equalTo("messageId", contentId) + .findAll()) + } + } + } + realm.insertOrUpdate(message) + } } - realm.insertOrUpdate(messages) } // Migrate blocked conversations from 2.7.3 @@ -126,46 +160,44 @@ class SyncRepositoryImpl @Inject constructor( // Sync conversations conversationCursor?.use { - val conversations = conversationCursor.map { cursor -> - progress++ - syncProgress.onNext(SyncRepository.SyncProgress.Running(max, progress, false)) - cursorToConversation.map(cursor).apply { - persistedData[id]?.let { persistedConversation -> - archived = persistedConversation.archived - blocked = persistedConversation.blocked - pinned = persistedConversation.pinned - name = persistedConversation.name - blockingClient = persistedConversation.blockingClient - blockReason = persistedConversation.blockReason + conversationCursor.forEach { cursor -> + tryOrNull { + progress++ + syncProgress.onNext(SyncRepository.SyncProgress.Running(max, progress, false)) + val conversation = cursorToConversation.map(cursor).apply { + persistedData[id]?.let { persistedConversation -> + archived = persistedConversation.archived + blocked = persistedConversation.blocked + pinned = persistedConversation.pinned + name = persistedConversation.name + blockingClient = persistedConversation.blockingClient + blockReason = persistedConversation.blockReason + } + lastMessage = realm.where(Message::class.java) + .sort("date", Sort.DESCENDING) + .equalTo("threadId", id) + .findFirst() } + realm.insertOrUpdate(conversation) } } - - realm.where(Message::class.java) - .sort("date", Sort.DESCENDING) - .distinct("threadId") - .findAll() - .forEach { message -> - val conversation = conversations.find { conversation -> conversation.id == message.threadId } - conversation?.lastMessage = message - } - - realm.insertOrUpdate(conversations) } // Sync recipients recipientCursor?.use { - val contacts = realm.copyToRealm(getContacts()) - val recipients = recipientCursor.map { cursor -> - progress++ - syncProgress.onNext(SyncRepository.SyncProgress.Running(max, progress, false)) - cursorToRecipient.map(cursor).apply { - contact = contacts.firstOrNull { contact -> - contact.numbers.any { phoneNumberUtils.compare(address, it.address) } + val contacts = realm.copyToRealmOrUpdate(getContacts()) + recipientCursor.forEach { cursor -> + tryOrNull { + progress++ + syncProgress.onNext(SyncRepository.SyncProgress.Running(max, progress, false)) + val recipient = cursorToRecipient.map(cursor).apply { + contact = contacts.firstOrNull { contact -> + contact.numbers.any { phoneNumberUtils.compare(address, it.address) } + } } + realm.insertOrUpdate(recipient) } } - realm.insertOrUpdate(recipients) } syncProgress.onNext(SyncRepository.SyncProgress.Running(0, 0, true)) @@ -178,7 +210,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? { @@ -219,6 +251,12 @@ class SyncRepositoryImpl @Inject constructor( cursorToMessage.map(Pair(cursor, columnsMap)).apply { existingId?.let { this.id = it } + if (isMms()) { + parts = RealmList().apply { + addAll(cursorToPart.getPartsCursor(contentId)?.map { cursorToPart.map(it) }.orEmpty()) + } + } + conversationRepo.getOrCreateConversation(threadId) insertOrUpdate() } @@ -234,63 +272,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 } + .forEach { number -> + number.isDefault = defaultNumberIds.any { id -> id == number.id } + val duplicate = uniqueNumbers.find { other -> + phoneNumberUtils.compare(number.address, other.address) + } + + if (duplicate == null) { + uniqueNumbers += number + } else if (!duplicate.isDefault && number.isDefault) { + duplicate.isDefault = true + } + } + 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/service/AutoDeleteService.kt b/data/src/main/java/com/moez/QKSMS/service/AutoDeleteService.kt new file mode 100644 index 0000000000000000000000000000000000000000..2f09687b3c2c2f8a0176a62c0be1dd4491cce84a --- /dev/null +++ b/data/src/main/java/com/moez/QKSMS/service/AutoDeleteService.kt @@ -0,0 +1,67 @@ +package com.moez.QKSMS.service + +import android.annotation.SuppressLint +import android.app.PendingIntent.getActivity +import android.app.job.JobInfo +import android.app.job.JobParameters +import android.app.job.JobScheduler +import android.app.job.JobService +import android.content.ComponentName +import android.content.Context +import androidx.core.content.ContextCompat.getSystemService +import androidx.core.content.getSystemService +//import com.moez.QKSMS.common.util.extensions.jobScheduler +import com.moez.QKSMS.interactor.DeleteOldMessages +import dagger.android.AndroidInjection +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.plusAssign +import kotlinx.coroutines.Job +import timber.log.Timber +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class AutoDeleteService : JobService() { + + companion object { + private lateinit var jobScheduler: JobScheduler + private const val JobId = 8120235 + + @SuppressLint("MissingPermission") // Added in [presentation]'s AndroidManifest.xml + fun scheduleJob(context: Context) { + Timber.i("Scheduling job") + val serviceComponent = ComponentName(context, AutoDeleteService::class.java) + val periodicJob = JobInfo.Builder(JobId, serviceComponent) + .setPeriodic(TimeUnit.DAYS.toMillis(1)) + .setPersisted(true) + .build() + jobScheduler=context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler + + jobScheduler.schedule(periodicJob) + } + + fun cancelJob(context: Context) { + Timber.i("Canceling job") + jobScheduler.cancel(JobId) + } + } + + @Inject lateinit var deleteOldMessages: DeleteOldMessages + + private val disposables = CompositeDisposable() + + override fun onStartJob(params: JobParameters?): Boolean { + Timber.i("onStartJob") + AndroidInjection.inject(this) + disposables += deleteOldMessages + deleteOldMessages.execute(Unit) { + jobFinished(params, false) + } + return true + } + + override fun onStopJob(params: JobParameters?): Boolean { + Timber.i("onStopJob") + disposables.dispose() + return true + } +} 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..f3a458dbbdb84a0b255b8b6ea01dcfaad70cdc71 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)) + } -} \ 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/data/src/main/java/com/moez/QKSMS/util/NightModeManager.kt b/data/src/main/java/com/moez/QKSMS/util/NightModeManager.kt index 94370420158bcc244eaef5a9c6ac01ee8ea09f32..8cfac4158ec9c7c9ad2b2f6d521d95af42fe16d0 100644 --- a/data/src/main/java/com/moez/QKSMS/util/NightModeManager.kt +++ b/data/src/main/java/com/moez/QKSMS/util/NightModeManager.kt @@ -18,6 +18,7 @@ */ package com.moez.QKSMS.util + import android.app.AlarmManager import android.app.PendingIntent import android.content.Context @@ -33,20 +34,99 @@ import javax.inject.Singleton @Singleton class NightModeManager @Inject constructor( - private val context: Context, - private val prefs: Preferences, - private val widgetManager: WidgetManager + private val context: Context, + private val prefs: Preferences, + private val widgetManager: WidgetManager ) { fun updateCurrentTheme() { - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + when (prefs.nightMode.get()) { + Preferences.NIGHT_MODE_SYSTEM -> { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + } + + Preferences.NIGHT_MODE_OFF -> { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + } + + Preferences.NIGHT_MODE_ON -> { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + } + + Preferences.NIGHT_MODE_AUTO -> { + val nightStartTime = getPreviousInstanceOfTime(prefs.nightStart.get()) + val nightEndTime = getPreviousInstanceOfTime(prefs.nightEnd.get()) + + // If the last nightStart was more recent than the last nightEnd, then it's night time + val night = nightStartTime > nightEndTime + prefs.night.set(night) + AppCompatDelegate.setDefaultNightMode(when (night) { + true -> AppCompatDelegate.MODE_NIGHT_YES + false -> AppCompatDelegate.MODE_NIGHT_NO + }) + widgetManager.updateTheme() + } + } } fun updateNightMode(mode: Int) { - prefs.nightMode.set(Preferences.NIGHT_MODE_SYSTEM) - prefs.night.set(false) - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + prefs.nightMode.set(mode) + + // If it's not on auto mode, set the appropriate night mode + if (mode != Preferences.NIGHT_MODE_AUTO) { + prefs.night.set(mode == Preferences.NIGHT_MODE_ON) + AppCompatDelegate.setDefaultNightMode(when (mode) { + Preferences.NIGHT_MODE_OFF -> AppCompatDelegate.MODE_NIGHT_NO + Preferences.NIGHT_MODE_ON -> AppCompatDelegate.MODE_NIGHT_YES + Preferences.NIGHT_MODE_SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + else -> AppCompatDelegate.MODE_NIGHT_NO + }) widgetManager.updateTheme() + } + + updateAlarms() + } + + fun setNightStart(hour: Int, minute: Int) { + prefs.nightStart.set("$hour:$minute") + updateAlarms() + } + + fun setNightEnd(hour: Int, minute: Int) { + prefs.nightEnd.set("$hour:$minute") + updateAlarms() + } + + private fun updateAlarms() { + val dayCalendar = createCalendar(prefs.nightEnd.get()) + val day = Intent(context, NightModeReceiver::class.java) + val dayIntent = PendingIntent.getBroadcast(context, 0, day, 0) + + val nightCalendar = createCalendar(prefs.nightStart.get()) + val night = Intent(context, NightModeReceiver::class.java) + val nightIntent = PendingIntent.getBroadcast(context, 1, night, 0) + + context.sendBroadcast(day) + context.sendBroadcast(night) + + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + if (prefs.nightMode.get() == Preferences.NIGHT_MODE_AUTO) { + alarmManager.setInexactRepeating( + AlarmManager.RTC_WAKEUP, + dayCalendar.timeInMillis, + AlarmManager.INTERVAL_DAY, + dayIntent + ) + alarmManager.setInexactRepeating( + AlarmManager.RTC_WAKEUP, + nightCalendar.timeInMillis, + AlarmManager.INTERVAL_DAY, + nightIntent + ) + } else { + alarmManager.cancel(dayIntent) + alarmManager.cancel(nightIntent) + } } private fun createCalendar(time: String): Calendar { diff --git a/data/src/noAnalytics/java/com/moez/QKSMS/manager/ReferralManagerImpl.kt b/data/src/noAnalytics/java/com/moez/QKSMS/manager/ReferralManagerImpl.kt new file mode 100644 index 0000000000000000000000000000000000000000..0f51410f5882ead61260735bb1c261cdd6e34c6d --- /dev/null +++ b/data/src/noAnalytics/java/com/moez/QKSMS/manager/ReferralManagerImpl.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2020 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.manager + +import android.content.Context +import com.moez.QKSMS.util.Preferences +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import kotlin.coroutines.resume + +class ReferralManagerImpl @Inject constructor() : ReferralManager { + + override suspend fun trackReferrer() { + } + +} diff --git a/data/src/withAnalytics/java/com/moez/QKSMS/manager/AnalyticsManagerImpl.kt b/data/src/withAnalytics/java/com/moez/QKSMS/manager/AnalyticsManagerImpl.kt index edf3649848d645f4fed9cccb83d69033348eb4ce..2cc27c1c22aa861ca75558936f926142b464eedd 100644 --- a/data/src/withAnalytics/java/com/moez/QKSMS/manager/AnalyticsManagerImpl.kt +++ b/data/src/withAnalytics/java/com/moez/QKSMS/manager/AnalyticsManagerImpl.kt @@ -22,7 +22,6 @@ import android.content.Context import com.amplitude.api.Amplitude import com.amplitude.api.AmplitudeClient import com.amplitude.api.Identify -import com.mixpanel.android.mpmetrics.MixpanelAPI import com.moez.QKSMS.data.BuildConfig import org.json.JSONArray import org.json.JSONObject @@ -34,7 +33,6 @@ import javax.inject.Singleton class AnalyticsManagerImpl @Inject constructor(context: Context) : AnalyticsManager { private val amplitude: AmplitudeClient = Amplitude.getInstance().initialize(context, BuildConfig.AMPLITUDE_API_KEY) - private val mixpanel: MixpanelAPI = MixpanelAPI.getInstance(context, BuildConfig.MIXPANEL_API_KEY) init { amplitude.trackSessionEvents(true) @@ -47,20 +45,11 @@ class AnalyticsManagerImpl @Inject constructor(context: Context) : AnalyticsMana .also { Timber.v("$event: $it") } amplitude.logEvent(event, propertiesJson) - - synchronized(mixpanel) { - mixpanel.track(event, propertiesJson) - } } override fun setUserProperty(key: String, value: Any) { Timber.v("$key: $value") - // Set the value in Mixpanel - val properties = JSONObject() - properties.put(key, value) - mixpanel.registerSuperProperties(properties) - // Set the value in Amplitude val identify = Identify() when (value) { diff --git a/data/src/withAnalytics/java/com/moez/QKSMS/manager/ReferralManagerImpl.kt b/data/src/withAnalytics/java/com/moez/QKSMS/manager/ReferralManagerImpl.kt new file mode 100644 index 0000000000000000000000000000000000000000..32a48cc0a8860dc6e4a67e8c63da937b4d2ca011 --- /dev/null +++ b/data/src/withAnalytics/java/com/moez/QKSMS/manager/ReferralManagerImpl.kt @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2020 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.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/domain/build.gradle b/domain/build.gradle index fe7c5c19e4b0d87b2a7d073e72ba0283b25ba4e8..e7f4a6d132a205f7bcb48b0125184d9fc257ecc8 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -19,10 +19,11 @@ 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 { - compileSdkVersion 29 + compileSdkVersion 30 compileOptions { sourceCompatibility 1.8 @@ -31,16 +32,16 @@ android { defaultConfig { minSdkVersion 21 - targetSdkVersion 28 // Don't upgrade to 29 until we support Scoped storage + targetSdkVersion 29 } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() + jvmTarget = JavaVersion.VERSION_11.toString() } } @@ -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/blocking/BlockingClient.kt b/domain/src/main/java/com/moez/QKSMS/blocking/BlockingClient.kt index 97534f8f84d7c661d3d7c9a49dfab48b1f80e80c..13c6b9edf8a29065c8c2f50b3938551126eff867 100644 --- a/domain/src/main/java/com/moez/QKSMS/blocking/BlockingClient.kt +++ b/domain/src/main/java/com/moez/QKSMS/blocking/BlockingClient.kt @@ -37,6 +37,14 @@ interface BlockingClient { // This means there's a good chance that if a number is blocked in QK, it won't be blocked there, so we // shouldn't unblock the conversation in that case object DoNothing : Action() + + override fun toString(): String { + return when (this) { + is Block -> "Block" + is Unblock -> "Unblock" + is DoNothing -> "DoNothing" + } + } } /** @@ -52,7 +60,14 @@ interface BlockingClient { /** * Returns the recommendation action to perform given a message from the [address] */ - fun getAction(address: String): Single + fun shouldBlock(address: String): Single + + /** + * Returns whether or not the [address] is in the blocking manager's blacklist + * In most cases this will return the same result as [shouldBlock], but it's possible for an app's blacklist + * to be temporarily deactivated, in which case the results will differ + */ + fun isBlacklisted(address: String): Single /** * Blocks the numbers or opens the manager diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/CancelDelayedMessage.kt b/domain/src/main/java/com/moez/QKSMS/interactor/CancelDelayedMessage.kt index 4c37e3fcdb18e14108da09b452f32bda8eb67324..429c2a754a5b1f1d9b0a4df5b3e3f31f0b5130f0 100644 --- a/domain/src/main/java/com/moez/QKSMS/interactor/CancelDelayedMessage.kt +++ b/domain/src/main/java/com/moez/QKSMS/interactor/CancelDelayedMessage.kt @@ -18,16 +18,23 @@ */ package com.moez.QKSMS.interactor +import com.moez.QKSMS.repository.ConversationRepository import com.moez.QKSMS.repository.MessageRepository import io.reactivex.Flowable import javax.inject.Inject -class CancelDelayedMessage @Inject constructor(private val messageRepo: MessageRepository) : Interactor() { +class CancelDelayedMessage @Inject constructor( + private val conversationRepo: ConversationRepository, + private val messageRepo: MessageRepository +) : Interactor() { - override fun buildObservable(params: Long): Flowable<*> { - return Flowable.just(params) - .doOnNext { id -> messageRepo.cancelDelayedSms(id) } - .doOnNext { id -> messageRepo.deleteMessages(id) } + data class Params(val messageId: Long, val threadId: Long) + + override fun buildObservable(params: Params): Flowable<*> { + return Flowable.just(Unit) + .doOnNext { messageRepo.cancelDelayedSms(params.messageId) } + .doOnNext { messageRepo.deleteMessages(params.messageId) } + .doOnNext { conversationRepo.updateConversations(params.threadId) } // Update the conversation } -} \ No newline at end of file +} diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/DeleteMessages.kt b/domain/src/main/java/com/moez/QKSMS/interactor/DeleteMessages.kt index 8c0969b63179cdcc4aa5f80d1590724ccbcc2c1b..3f573cdf1f62cff32e033a997f590b51df42b3cf 100644 --- a/domain/src/main/java/com/moez/QKSMS/interactor/DeleteMessages.kt +++ b/domain/src/main/java/com/moez/QKSMS/interactor/DeleteMessages.kt @@ -31,15 +31,13 @@ class DeleteMessages @Inject constructor( private val updateBadge: UpdateBadge ) : Interactor() { - data class Params(val messageIds: List, val threadId: Long? = null) + data class Params(val messageIds: List, val threadId: Long) override fun buildObservable(params: Params): Flowable<*> { return Flowable.just(params.messageIds.toLongArray()) .doOnNext { messageIds -> messageRepo.deleteMessages(*messageIds) } // Delete the messages - .doOnNext { - params.threadId?.let { conversationRepo.updateConversations(it) } // Update the conversation - } - .doOnNext { params.threadId?.let { notificationManager.update(it) } } + .doOnNext { conversationRepo.updateConversations(params.threadId) } // Update the conversation + .doOnNext { notificationManager.update(params.threadId) } .flatMap { updateBadge.buildObservable(Unit) } // Update the badge } diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/DeleteOldMessages.kt b/domain/src/main/java/com/moez/QKSMS/interactor/DeleteOldMessages.kt new file mode 100644 index 0000000000000000000000000000000000000000..7479933db6ae85ee5ff8a9c1399fc2307a2e8404 --- /dev/null +++ b/domain/src/main/java/com/moez/QKSMS/interactor/DeleteOldMessages.kt @@ -0,0 +1,44 @@ +/* + * 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.interactor + +import com.moez.QKSMS.repository.ConversationRepository +import com.moez.QKSMS.repository.MessageRepository +import com.moez.QKSMS.util.Preferences +import io.reactivex.Flowable +import timber.log.Timber +import javax.inject.Inject + +class DeleteOldMessages @Inject constructor( + private val conversationRepo: ConversationRepository, + private val messageRepo: MessageRepository, + private val prefs: Preferences +) : Interactor() { + + override fun buildObservable(params: Unit): Flowable<*> = Flowable.fromCallable { + val maxAge = prefs.autoDelete.get().takeIf { it > 0 } ?: return@fromCallable + val counts = messageRepo.getOldMessageCounts(maxAge) + val threadIds = counts.keys.toLongArray() + + Timber.d("Deleting ${counts.values.sum()} old messages from ${threadIds.size} conversations") + messageRepo.deleteOldMessages(maxAge) + conversationRepo.updateConversations(*threadIds) + } + +} diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/ReceiveMms.kt b/domain/src/main/java/com/moez/QKSMS/interactor/ReceiveMms.kt index 0d1ff0838526c2d1143c6b5e5616fe80838baa4b..e67c7a7989705c88fee743df71d4a2363374a446 100644 --- a/domain/src/main/java/com/moez/QKSMS/interactor/ReceiveMms.kt +++ b/domain/src/main/java/com/moez/QKSMS/interactor/ReceiveMms.kt @@ -57,7 +57,7 @@ class ReceiveMms @Inject constructor( // to check if it should be blocked after we've pulled it into realm. If it // turns out that it should be dropped, then delete it // TODO Don't store blocked messages in the first place - val action = blockingClient.getAction(message.address).blockingGet() + val action = blockingClient.shouldBlock(message.address).blockingGet() val shouldDrop = prefs.drop.get() Timber.v("block=$action, drop=$shouldDrop") diff --git a/domain/src/main/java/com/moez/QKSMS/interactor/ReceiveSms.kt b/domain/src/main/java/com/moez/QKSMS/interactor/ReceiveSms.kt index f2b4c6d343861654961e9a0ceb552ced0217f8d6..c8fbeddbbf4b4ff5ffd77f9ed736fe2be2e3d64d 100644 --- a/domain/src/main/java/com/moez/QKSMS/interactor/ReceiveSms.kt +++ b/domain/src/main/java/com/moez/QKSMS/interactor/ReceiveSms.kt @@ -49,7 +49,7 @@ class ReceiveSms @Inject constructor( // Don't continue if the sender is blocked val messages = it.messages val address = messages[0].displayOriginatingAddress - val action = blockingClient.getAction(address).blockingGet() + val action = blockingClient.shouldBlock(address).blockingGet() val shouldDrop = prefs.drop.get() Timber.v("block=$action, drop=$shouldDrop") 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/BillingManager.kt b/domain/src/main/java/com/moez/QKSMS/manager/BillingManager.kt new file mode 100644 index 0000000000000000000000000000000000000000..9fa64c3b2014f289fb0026d4cf1f2653efc42427 --- /dev/null +++ b/domain/src/main/java/com/moez/QKSMS/manager/BillingManager.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2020 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.manager + +import android.app.Activity +import io.reactivex.Observable + + +interface BillingManager { + + companion object { + const val SKU_PLUS = "remove_ads" + const val SKU_PLUS_DONATE = "qksms_plus_donate" + } + data class Product( + val sku: String, + val price: String, + val priceCurrencyCode: String + ) + val products: Observable> + val upgradeStatus: Observable + + suspend fun checkForPurchases() + + suspend fun queryProducts() + + suspend fun initiatePurchaseFlow(activity: Activity, sku: String) + +} diff --git a/domain/src/main/java/com/moez/QKSMS/manager/ChangelogManager.kt b/domain/src/main/java/com/moez/QKSMS/manager/ChangelogManager.kt index 37f714b787bd413b3142e5d2f4db587529a0b122..4ba22f8b810eb6e440702e201098f3bc6c29ec94 100644 --- a/domain/src/main/java/com/moez/QKSMS/manager/ChangelogManager.kt +++ b/domain/src/main/java/com/moez/QKSMS/manager/ChangelogManager.kt @@ -18,11 +18,9 @@ */ package com.moez.QKSMS.manager -import io.reactivex.Single - interface ChangelogManager { - data class Changelog( + data class CumulativeChangelog( val added: List, val improved: List, val fixed: List @@ -33,7 +31,7 @@ interface ChangelogManager { */ fun didUpdate(): Boolean - fun getChangelog(): Single + suspend fun getChangelog(): CumulativeChangelog fun markChangelogSeen() 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/mapper/CursorToPart.kt b/domain/src/main/java/com/moez/QKSMS/mapper/CursorToPart.kt index bb3922033836eadc0ed35cf3c014c6995f8515a4..e4ae1acc6a9abad8670d3638bb3edd98698fec47 100644 --- a/domain/src/main/java/com/moez/QKSMS/mapper/CursorToPart.kt +++ b/domain/src/main/java/com/moez/QKSMS/mapper/CursorToPart.kt @@ -23,6 +23,6 @@ import com.moez.QKSMS.model.MmsPart interface CursorToPart : Mapper { - fun getPartsCursor(messageId: Long): Cursor? + fun getPartsCursor(messageId: Long? = null): Cursor? -} \ No newline at end of file +} diff --git a/domain/src/main/java/com/moez/QKSMS/mapper/Mapper.kt b/domain/src/main/java/com/moez/QKSMS/mapper/Mapper.kt index 7f726e8c84bd9efe6a80d8cf008979eb4b2073de..29430f634e50b1b777ce3463bff1a06aade3402d 100644 --- a/domain/src/main/java/com/moez/QKSMS/mapper/Mapper.kt +++ b/domain/src/main/java/com/moez/QKSMS/mapper/Mapper.kt @@ -22,4 +22,4 @@ interface Mapper { fun map(from: From): To -} \ No newline at end of file +} 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/MmsPart.kt b/domain/src/main/java/com/moez/QKSMS/model/MmsPart.kt index d1afb35d545948e1c6ad66e89da36f9c5d7c3c62..aa3fa9082cb7456569fc251495916509246630ce 100644 --- a/domain/src/main/java/com/moez/QKSMS/model/MmsPart.kt +++ b/domain/src/main/java/com/moez/QKSMS/model/MmsPart.kt @@ -21,12 +21,14 @@ package com.moez.QKSMS.model import androidx.core.net.toUri import io.realm.RealmObject import io.realm.RealmResults +import io.realm.annotations.Index import io.realm.annotations.LinkingObjects import io.realm.annotations.PrimaryKey open class MmsPart : RealmObject() { @PrimaryKey var id: Long = 0 + @Index var messageId: Long = 0 var type: String = "" var seq: Int = -1 var name: String? = null 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..e94d1c9faf565d24ebf002d5d7b6cfd14c5737fe 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? @@ -84,4 +91,4 @@ interface ConversationRepository { fun deleteConversations(vararg threadIds: Long) -} \ No newline at end of file +} 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..a3ef7c19fa4bf716c9d8cd56c84b6c511676d349 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? @@ -100,4 +102,14 @@ interface MessageRepository { fun deleteMessages(vararg messageIds: Long) + /** + * Returns the number of messages older than [maxAgeDays] per conversation + */ + fun getOldMessageCounts(maxAgeDays: Int): Map + + /** + * Deletes all messages older than [maxAgeDays] + */ + fun deleteOldMessages(maxAgeDays: Int) + } 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 16dfe84d86786c542d881daf44afa389efefa694..8eac88fdbaea2970907b10b1965b109c596da2cb 100644 --- a/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt +++ b/domain/src/main/java/com/moez/QKSMS/util/Preferences.kt @@ -20,6 +20,7 @@ package com.moez.QKSMS.util import android.R import android.content.Context +import android.content.SharedPreferences import android.os.Build import android.provider.Settings import android.util.TypedValue @@ -28,11 +29,16 @@ import androidx.core.content.res.ResourcesCompat 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(private val 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 @@ -50,10 +56,12 @@ class Preferences @Inject constructor(private val context: Context, private val const val NOTIFICATION_PREVIEWS_NONE = 2 const val NOTIFICATION_ACTION_NONE = 0 - const val NOTIFICATION_ACTION_READ = 1 - const val NOTIFICATION_ACTION_REPLY = 2 - const val NOTIFICATION_ACTION_CALL = 3 - const val NOTIFICATION_ACTION_DELETE = 4 + const val NOTIFICATION_ACTION_ARCHIVE = 1 + const val NOTIFICATION_ACTION_DELETE = 2 + const val NOTIFICATION_ACTION_BLOCK = 3 + const val NOTIFICATION_ACTION_CALL = 4 + const val NOTIFICATION_ACTION_READ = 5 + const val NOTIFICATION_ACTION_REPLY = 6 const val SEND_DELAY_NONE = 0 const val SEND_DELAY_SHORT = 1 @@ -63,29 +71,36 @@ class Preferences @Inject constructor(private val context: Context, private val const val SWIPE_ACTION_NONE = 0 const val SWIPE_ACTION_ARCHIVE = 1 const val SWIPE_ACTION_DELETE = 2 - const val SWIPE_ACTION_CALL = 3 - const val SWIPE_ACTION_READ = 4 - const val SWIPE_ACTION_UNREAD = 5 + const val SWIPE_ACTION_BLOCK = 3 + const val SWIPE_ACTION_CALL = 4 + const val SWIPE_ACTION_READ = 5 + const val SWIPE_ACTION_UNREAD = 6 const val BLOCKING_MANAGER_QKSMS = 0 const val BLOCKING_MANAGER_CC = 1 const val BLOCKING_MANAGER_SIA = 2 + const val BLOCKING_MANAGER_CB = 3 } // 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) val changelogVersion = rxPrefs.getInteger("changelogVersion", context.versionCode) - @Deprecated("This should only be accessed when migrating to @blockingManager") val sia = rxPrefs.getBoolean("sia", false) // User configurable - val nightMode = rxPrefs.getInteger("nightMode", NIGHT_MODE_SYSTEM) + val sendAsGroup = rxPrefs.getBoolean("sendAsGroup", true) + val nightMode = rxPrefs.getInteger("nightMode", when (Build.VERSION.SDK_INT >= 29) { + true -> NIGHT_MODE_SYSTEM + false -> NIGHT_MODE_OFF + }) 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) @@ -103,6 +118,8 @@ class Preferences @Inject constructor(private val context: Context, private val val signature = rxPrefs.getString("signature", "") val unicode = rxPrefs.getBoolean("unicode", false) val mobileOnly = rxPrefs.getBoolean("mobileOnly", false) + val autoDelete = rxPrefs.getInteger("autoDelete", 0) + val longAsMms = rxPrefs.getBoolean("longAsMms", false) val mmsSize = rxPrefs.getInteger("mmsSize", 300) val logging = rxPrefs.getBoolean("logging", false) @@ -110,11 +127,41 @@ class Preferences @Inject constructor(private val context: Context, private val // Migrate from old night mode preference to new one, now that we support android Q night mode val nightModeSummary = rxPrefs.getInteger("nightModeSummary") if (nightModeSummary.isSet) { - nightMode.set(NIGHT_MODE_SYSTEM) + nightMode.set(when (nightModeSummary.get()) { + 0 -> NIGHT_MODE_OFF + 1 -> NIGHT_MODE_ON + 2 -> NIGHT_MODE_AUTO + else -> NIGHT_MODE_OFF + }) nightModeSummary.delete() } } + /** + * 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) + } + + 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) + } + } fun notifications(threadId: Long = 0): Preference { val default = rxPrefs.getBoolean("notifications", true) @@ -134,6 +181,15 @@ class Preferences @Inject constructor(private val context: Context, private val } } + 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) @@ -151,4 +207,4 @@ class Preferences @Inject constructor(private val context: Context, private val else -> rxPrefs.getString("ringtone_$threadId", default.get()) } } -} \ No newline at end of file +} diff --git a/gradle.properties b/gradle.properties index bb91abfe1f7fc7f9bdcf3ff2dff9365ce92956a3..5cc4715de81b2234ee761946a1aa12c0788c027d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,6 +13,7 @@ org.gradle.jvmargs=-Xmx1536m # https://blog.gradle.org/introducing-gradle-build-cache org.gradle.caching=true +android.enableR8=true android.useAndroidX=true android.enableJetifier=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 6914a81f75ef85693e46ba0e16291df0ad72d9ae..9118bd9e6daff061277d9b6881bc5d39846f9ba8 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 +#Wed Mar 09 07:51:50 IST 2022 distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip +zipStoreBase=GRADLE_USER_HOME diff --git a/presentation/build.gradle b/presentation/build.gradle index de7cb762b133cc3e5b94bc1ad9dc67b4a08005dd..569fa6c4a3ab0dc39bdf52a569cb124eb3730210 100644 --- a/presentation/build.gradle +++ b/presentation/build.gradle @@ -23,17 +23,19 @@ apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' android { - compileSdkVersion 29 - buildToolsVersion "29.0.2" + compileSdkVersion 31 flavorDimensions "analytics" 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 30 + versionCode 2218 + versionName "3.9.4" + setProperty("archivesBaseName", "QKSMS-v${versionName}") testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + setProperty("archivesBaseName", "QKSMS-v${versionName}") } @@ -61,12 +63,12 @@ android { } compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } - + // For Kotlin projects kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "11" } lintOptions { @@ -153,6 +155,7 @@ dependencies { implementation "com.google.dagger:dagger-android-support:$dagger_version" kapt "com.google.dagger:dagger-compiler:$dagger_version" kapt "com.google.dagger:dagger-android-processor:$dagger_version" + annotationProcessor "com.google.dagger:dagger-android-processor:$dagger_version" compileOnly "javax.annotation:jsr250-api:1.0" // ezvcard @@ -167,6 +170,7 @@ dependencies { kapt "io.realm:realm-annotations:$realm_version" kapt "io.realm:realm-annotations-processor:$realm_version" + // rxjava implementation "io.reactivex.rxjava2:rxandroid:$rxandroid_version" implementation "io.reactivex.rxjava2:rxjava:$rxjava_version" @@ -195,22 +199,21 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-reactive:$coroutines_version" - implementation "com.android.billingclient:billing:1.0" implementation "com.github.chrisbanes:PhotoView:2.0.0" implementation "com.f2prateek.rx.preferences2:rx-preferences:$rx_preferences_version" implementation "com.google.android:flexbox:0.3.1" implementation "com.jakewharton.timber:timber:$timber_version" implementation "com.squareup.moshi:moshi-kotlin:$moshi_version" - implementation "me.leolin:ShortcutBadger:1.1.21" + implementation "me.leolin:ShortcutBadger:1.1.22" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation project(":android-smsmms") implementation project(":common") implementation project(':data') implementation project(':domain') - withAnalyticsImplementation 'com.google.firebase:firebase-core:16.0.9' - withAnalyticsImplementation 'com.crashlytics.sdk.android:crashlytics:2.10.1' - + withAnalyticsImplementation 'com.google.firebase:firebase-crashlytics:17.3.0' + withAnalyticsImplementation "com.android.billingclient:billing:$billing_version" + withAnalyticsImplementation "com.android.billingclient:billing-ktx:$billing_version" noAnalyticsDebug project(path: ':data', configuration: 'noAnalyticsDebug') noAnalyticsRelease project(path: ':data', configuration: 'noAnalyticsRelease') withAnalyticsDebug project(path: ':data', configuration: 'withAnalyticsDebug') @@ -218,6 +221,6 @@ dependencies { } if (getGradle().getStartParameter().getTaskRequests().toString().contains("WithAnalytics")) { - apply plugin: 'io.fabric' apply plugin: 'com.google.gms.google-services' + apply plugin: 'com.google.firebase.crashlytics' } diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index d670095f9312fa11b0ddd085cd5bdfa98a396b93..b126a42c0655e2b1b4faf230e1a1256aa8823e6e 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -31,6 +31,7 @@ + @@ -40,6 +41,7 @@ android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:requestLegacyExternalStorage="true" android:supportsRtl="true" android:theme="@style/AppLaunchTheme"> @@ -87,6 +89,11 @@ + + + + + + @@ -146,6 +154,8 @@ + + @@ -173,6 +183,9 @@ + - @Inject - lateinit var dispatchingBroadcastReceiverInjector: DispatchingAndroidInjector - @Inject - lateinit var dispatchingServiceInjector: DispatchingAndroidInjector + @Inject lateinit var qkMigration: QkMigration - @Inject - lateinit var fileLoggingTree: FileLoggingTree - @Inject - lateinit var nightModeManager: NightModeManager + @Inject lateinit var billingManager: BillingManager + @Inject lateinit var dispatchingActivityInjector: DispatchingAndroidInjector + @Inject lateinit var dispatchingBroadcastReceiverInjector: DispatchingAndroidInjector + @Inject lateinit var dispatchingServiceInjector: DispatchingAndroidInjector + @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() + billingManager.checkForPurchases() + billingManager.queryProducts() } nightModeManager.updateCurrentTheme() + val fontRequest = FontRequest( "com.google.android.gms.fonts", "com.google.android.gms", @@ -97,7 +100,6 @@ class QKApplication : Application(), HasActivityInjector, HasBroadcastReceiverIn R.array.com_google_android_gms_fonts_certs) EmojiCompat.init(FontRequestEmojiCompatConfig(this, fontRequest)) - Timber.plant(Timber.DebugTree(), fileLoggingTree) Timber.plant(Timber.DebugTree(), CrashlyticsTree(), fileLoggingTree) 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 81c0e2cb580622c8d0434930fc869e225455e5fc..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 @@ -24,13 +24,18 @@ import android.graphics.BitmapFactory import android.os.Build import android.os.Bundle import android.view.View -import androidx.core.content.ContextCompat import androidx.core.view.iterator import androidx.lifecycle.Lifecycle 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,10 +56,11 @@ import javax.inject.Inject */ abstract class QkThemedActivity : QkActivity() { - @Inject - lateinit var colors: Colors - @Inject - lateinit var prefs: Preferences + @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 /** * In case the activity should be themed for a specific conversation, the selected conversation @@ -62,9 +68,38 @@ abstract class QkThemedActivity : QkActivity() { */ val threadId: Subject = BehaviorSubject.createDefault(0) + /** + * Switch the theme if the threadId changes + * Set it based on the latest message in the conversation + */ + val theme: Observable = threadId + .distinctUntilChanged() + .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?) { - setTheme(R.style.AppTheme) + setTheme(getActivityThemeRes(prefs.black.get())) super.onCreate(savedInstanceState) // When certain preferences change, we need to recreate the activity @@ -82,6 +117,11 @@ abstract class QkThemedActivity : QkActivity() { View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR } + // Some devices don't let you modify android.R.attr.navigationBarColor + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + window.navigationBarColor = resolveThemeColor(android.R.attr.windowBackground) + } + // Set the color for the recent apps title val toolbarColor = resolveThemeColor(R.attr.colorPrimary) val icon = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher) @@ -93,11 +133,32 @@ abstract class QkThemedActivity : QkActivity() { super.onPostCreate(savedInstanceState) // Set the color for the overflow and navigation icon - val textSecondary = ContextCompat.getColor(this, R.color.colorAccent) + val textSecondary = resolveThemeColor(android.R.attr.textColorSecondary) toolbar?.overflowIcon = toolbar?.overflowIcon?.apply { setTint(textSecondary) } + + // Update the colours of the menu items + Observables.combineLatest(menu, theme) { menu, theme -> + menu.iterator().forEach { menuItem -> + val tint = when (menuItem.itemId) { + in getColoredMenuItems() -> theme.theme + else -> textSecondary + } + + menuItem.icon = menuItem.icon?.apply { setTint(tint) } + } + }.autoDisposable(scope(Lifecycle.Event.ON_DESTROY)).subscribe() } open fun getColoredMenuItems(): List { return listOf() } + + /** + * This can be overridden in case an activity does not want to use the default themes + */ + open fun getActivityThemeRes(black: Boolean) = when { + black -> R.style.AppTheme_Black + else -> R.style.AppTheme + } + } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/common/util/BillingManager.kt b/presentation/src/main/java/com/moez/QKSMS/common/util/BillingManager.kt deleted file mode 100644 index 69f0c0654661488513d346b93209a291e7b2f252..0000000000000000000000000000000000000000 --- a/presentation/src/main/java/com/moez/QKSMS/common/util/BillingManager.kt +++ /dev/null @@ -1,144 +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.common.util - -import android.app.Activity -import android.content.Context -import com.android.billingclient.api.BillingClient -import com.android.billingclient.api.BillingClient.BillingResponse -import com.android.billingclient.api.BillingClient.SkuType -import com.android.billingclient.api.BillingClientStateListener -import com.android.billingclient.api.BillingFlowParams -import com.android.billingclient.api.Purchase -import com.android.billingclient.api.PurchasesUpdatedListener -import com.android.billingclient.api.SkuDetails -import com.android.billingclient.api.SkuDetailsParams -import com.moez.QKSMS.BuildConfig -import com.moez.QKSMS.manager.AnalyticsManager -import io.reactivex.Flowable -import io.reactivex.Observable -import io.reactivex.schedulers.Schedulers -import io.reactivex.subjects.BehaviorSubject -import io.reactivex.subjects.Subject -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class BillingManager @Inject constructor( - context: Context, - private val analyticsManager: AnalyticsManager -) : PurchasesUpdatedListener { - - companion object { - const val SKU_PLUS = "remove_ads" - const val SKU_PLUS_DONATE = "qksms_plus_donate" - } - - val products: Observable> = BehaviorSubject.create() - val upgradeStatus: Observable - - private val skus = listOf(SKU_PLUS, SKU_PLUS_DONATE) - private val purchaseListObservable = BehaviorSubject.create>() - - private val billingClient: BillingClient = BillingClient.newBuilder(context).setListener(this).build() - private var isServiceConnected = false - - init { - startServiceConnection { - queryPurchases() - querySkuDetailsAsync() - } - - upgradeStatus = when (BuildConfig.FLAVOR) { - "noAnalytics" -> BehaviorSubject.createDefault(true) - - else -> purchaseListObservable - .map { purchases -> purchases.any { it.sku == SKU_PLUS } || purchases.any { it.sku == SKU_PLUS_DONATE } } - .doOnNext { upgraded -> analyticsManager.setUserProperty("Upgraded", upgraded) } - } - } - - private fun queryPurchases() { - executeServiceRequest { - // Load the cached data - purchaseListObservable.onNext(billingClient.queryPurchases(SkuType.INAPP).purchasesList.orEmpty()) - - // On a fresh device, the purchase might not be cached, and so we'll need to force a refresh - billingClient.queryPurchaseHistoryAsync(SkuType.INAPP) { _, _ -> - purchaseListObservable.onNext(billingClient.queryPurchases(SkuType.INAPP).purchasesList.orEmpty()) - } - } - } - - - private fun startServiceConnection(onSuccess: () -> Unit) { - val listener = object : BillingClientStateListener { - override fun onBillingSetupFinished(@BillingResponse billingResponseCode: Int) { - if (billingResponseCode == BillingResponse.OK) { - isServiceConnected = true - onSuccess() - } else { - Timber.w("Billing response: $billingResponseCode") - purchaseListObservable.onNext(listOf()) - } - } - - override fun onBillingServiceDisconnected() { - isServiceConnected = false - } - } - - Flowable.fromCallable { billingClient.startConnection(listener) } - .subscribeOn(Schedulers.io()) - .subscribe() - } - - private fun querySkuDetailsAsync() { - executeServiceRequest { - val subParams = SkuDetailsParams.newBuilder().setSkusList(skus).setType(BillingClient.SkuType.INAPP) - billingClient.querySkuDetailsAsync(subParams.build()) { responseCode, skuDetailsList -> - if (responseCode == BillingResponse.OK) { - (products as Subject).onNext(skuDetailsList) - } - } - } - } - - fun initiatePurchaseFlow(activity: Activity, sku: String) { - executeServiceRequest { - val params = BillingFlowParams.newBuilder().setSku(sku).setType(SkuType.INAPP) - billingClient.launchBillingFlow(activity, params.build()) - } - } - - private fun executeServiceRequest(runnable: () -> Unit) { - when (isServiceConnected) { - true -> runnable() - false -> startServiceConnection(runnable) - } - } - - override fun onPurchasesUpdated(resultCode: Int, purchases: List?) { - if (resultCode == BillingResponse.OK) { - purchaseListObservable.onNext(purchases.orEmpty()) - } - } - -} 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 eda797829acd8cee75a6b071ca304e2a91fe043a..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,48 +20,56 @@ 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) { - - /* init { - prefs.setSystemColor(R.color.tools_theme) - }*/ +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(R.color.tools_theme) } - val textPrimary by lazy { colors.textPrimaryOnThemeForColor(R.color.textPrimary) } - val textSecondary by lazy { colors.textSecondaryOnThemeForColor(R.color.textSecondary) } - val textTertiary by lazy { colors.textTertiaryOnThemeForColor(R.color.textTertiary) } + val highlight by lazy { colors.highlightColorForTheme(theme) } + val textPrimary by lazy { colors.textPrimaryOnThemeForColor(theme) } + val textSecondary by lazy { colors.textSecondaryOnThemeForColor(theme) } + 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 @@ -70,6 +78,25 @@ 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(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(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) } + } + fun highlightColorForTheme(theme: Int): Int = FloatArray(3) .apply { Color.colorToHSV(theme, this) } .let { hsv -> hsv.apply { set(2, 0.75f) } } // 75% value @@ -107,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/FileLoggingTree.kt b/presentation/src/main/java/com/moez/QKSMS/common/util/FileLoggingTree.kt index bf5c6c8a1a1032cad83ccc5c0b4f6ea99e82d74d..aa1dd63221d203cb57ae66f56de7518d2d446fc6 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/util/FileLoggingTree.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/util/FileLoggingTree.kt @@ -53,7 +53,7 @@ class FileLoggingTree @Inject constructor(private val prefs: Preferences) : Timb } // Format the log to be written to the file - val log = "$timestamp $priorityString/$tag: $message ${Log.getStackTraceString(t)}
".toByteArray() + val log = "$timestamp $priorityString/$tag: $message ${Log.getStackTraceString(t)}\n".toByteArray() // Ensure that only one thread is writing to the file at a time synchronized(fileLock) { @@ -62,7 +62,7 @@ class FileLoggingTree @Inject constructor(private val prefs: Preferences) : Timb val dir = File(Environment.getExternalStorageDirectory(), "QKSMS/Logs").apply { mkdirs() } // Create the file - val file = File(dir, "${SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(System.currentTimeMillis())}.html") + val file = File(dir, "${SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(System.currentTimeMillis())}.log") // Write the log to the file FileOutputStream(file, true).use { fileOutputStream -> fileOutputStream.write(log) } 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 3055d8d715284bbf3a91efe76ede2b6be564e4e2..cd2f4ad1ec5fe808a42c9723af1c47f83b8d7455 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 @@ -42,7 +45,9 @@ import com.moez.QKSMS.feature.compose.ComposeActivity import com.moez.QKSMS.feature.qkreply.QkReplyActivity import com.moez.QKSMS.manager.PermissionManager import com.moez.QKSMS.mapper.CursorToPartImpl +import com.moez.QKSMS.receiver.BlockThreadReceiver import com.moez.QKSMS.receiver.DeleteMessagesReceiver +import com.moez.QKSMS.receiver.MarkArchivedReceiver import com.moez.QKSMS.receiver.MarkReadReceiver import com.moez.QKSMS.receiver.MarkSeenReceiver import com.moez.QKSMS.receiver.RemoteMessagingReceiver @@ -76,19 +81,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() } /** @@ -105,24 +99,35 @@ class NotificationManagerImpl @Inject constructor( // If there are no messages to be displayed, make sure that the notification is dismissed if (messages.isEmpty()) { notificationManager.cancel(threadId.toInt()) + notificationManager.cancel(threadId.toInt() + 100000) return } 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) - val contentPI = taskStackBuilder.getPendingIntent(threadId.toInt() + 10000, PendingIntent.FLAG_UPDATE_CURRENT) + .addParentStack(ComposeActivity::class.java) + .addNextIntent(contentIntent) + val contentPI = taskStackBuilder.getPendingIntent(threadId.toInt(), 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(), 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) @@ -150,15 +155,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 +183,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 +205,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)) } } @@ -223,9 +230,37 @@ class NotificationManagerImpl @Inject constructor( .distinct() .mapNotNull { action -> when (action) { + Preferences.NOTIFICATION_ACTION_ARCHIVE -> { + val intent = Intent(context, MarkArchivedReceiver::class.java).putExtra("threadId", threadId) + val pi = PendingIntent.getBroadcast(context, threadId.toInt(), intent, + PendingIntent.FLAG_UPDATE_CURRENT) + NotificationCompat.Action.Builder(R.drawable.ic_archive_white_24dp, actionLabels[action], pi) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_ARCHIVE).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(), intent, + PendingIntent.FLAG_UPDATE_CURRENT) + NotificationCompat.Action.Builder(R.drawable.ic_delete_white_24dp, actionLabels[action], pi) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_DELETE).build() + } + + Preferences.NOTIFICATION_ACTION_BLOCK -> { + val intent = Intent(context, BlockThreadReceiver::class.java).putExtra("threadId", threadId) + val pi = PendingIntent.getBroadcast(context, threadId.toInt(), intent, + PendingIntent.FLAG_UPDATE_CURRENT) + NotificationCompat.Action.Builder(R.drawable.ic_block_white_24dp, actionLabels[action], pi) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MUTE).build() + } + 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(), 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 +270,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(), 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,19 +282,12 @@ 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(), 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) - NotificationCompat.Action.Builder(R.drawable.ic_delete_white_24dp, actionLabels[action], pi) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_DELETE).build() - } - else -> null } } @@ -274,6 +304,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,13 +325,19 @@ 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) val taskStackBuilder = TaskStackBuilder.create(context) - taskStackBuilder.addParentStack(ComposeActivity::class.java) - taskStackBuilder.addNextIntent(contentIntent) - val contentPI = taskStackBuilder.getPendingIntent(threadId.toInt() + 40000, PendingIntent.FLAG_UPDATE_CURRENT) + .addParentStack(ComposeActivity::class.java) + .addNextIntent(contentIntent) + val contentPI = taskStackBuilder.getPendingIntent(threadId.toInt(), PendingIntent.FLAG_UPDATE_CURRENT) val notification = NotificationCompat.Builder(context, getChannelIdForNotification(threadId)) .setContentTitle(context.getString(R.string.notification_message_failed_title)) @@ -304,14 +351,16 @@ class NotificationManagerImpl @Inject constructor( .setLights(Color.WHITE, 500, 2000) .setVibrate(if (prefs.vibration(threadId).get()) VIBRATE_PATTERN else longArrayOf(0)) - notificationManager.notify(threadId.toInt() + 50000, notification.build()) + notificationManager.notify(threadId.toInt() + 100000, notification.build()) } 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(), 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 +380,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 +422,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 +437,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/VCardExtension.kt b/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/VCardExtension.kt new file mode 100644 index 0000000000000000000000000000000000000000..8099e399c5478f4b61850b77d71686c5f440ff81 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/VCardExtension.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2021 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.util.extensions + +import ezvcard.VCard + +fun VCard.getDisplayName(): String? { + return formattedName?.value + ?: telephoneNumbers?.firstOrNull()?.text + ?: emails?.firstOrNull()?.value +} 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 800ff43b85e9222a951e45d6477979e8c4e48dc7..748b2d32a8733358e420071cc38c13aed5c9799d 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 @@ -38,66 +38,41 @@ import com.moez.QKSMS.util.Preferences 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 @Inject lateinit var prefs: Preferences - /** - * 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 fullName: 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 + fullName = recipient?.contact?.name + photoUri = recipient?.contact?.photoUri + lastUpdated = recipient?.contact?.lastUpdate + theme = colors.theme(recipient) updateView() } @@ -105,22 +80,26 @@ class AvatarView @JvmOverloads constructor(context: Context, attrs: AttributeSet super.onFinishInflate() if (!isInEditMode) { - applyTheme(threadId) updateView() } } - fun applyTheme(threadId: Long) { - setBackgroundTint(context.getColor(R.color.tools_theme)) - initial.setTextColor(context.getColor(R.color.white)) - icon.setTint(context.getColor(R.color.white)) - - } - private fun updateView() { - setBackgroundTint(context.getColor(R.color.tools_theme)) - if (name?.isNotEmpty() == true) { - initial.text = name?.substring(0, 1) + // Apply theme + setBackgroundTint(theme.theme) + initial.setTextColor(theme.textPrimary) + icon.setTint(theme.textPrimary) + + val initials = fullName + ?.substringBefore(',') + ?.split(" ").orEmpty() + .filter { name -> name.isNotEmpty() } + .map { name -> name[0] } + .filter { initial -> initial.isLetterOrDigit() } + .map { initial -> initial.toString() } + + if (initials.isNotEmpty()) { + initial.text = if (initials.size > 1) initials.first() + initials.last() else initials.first() icon.visibility = GONE } else { initial.text = null @@ -128,10 +107,9 @@ 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) } } 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 133cc7e00e58868264a47c0beb066e3794f93fc3..d4d9cfb8c5bac0718b97332c777777e7e922fa7c 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,6 +93,20 @@ class PagerTitleView @JvmOverloads constructor(context: Context, attrs: Attribut intArrayOf(android.R.attr.state_activated), intArrayOf(-android.R.attr.state_activated)) + recipientId + .distinctUntilChanged() + .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)) + } + .autoDisposable(ViewScopeProvider.from(this)) + .subscribe { colorStateList -> + childCount.forEach { index -> + (getChildAt(index) as? TextView)?.setTextColor(colorStateList) + } + } val textSecondary = context.resolveThemeColor(android.R.attr.textColorSecondary) childCount.forEach { index -> (getChildAt(index) as? TextView)?.setTextColor(ColorStateList(states, intArrayOf(R.color.tools_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/common/widget/FieldDialog.kt b/presentation/src/main/java/com/moez/QKSMS/common/widget/TextInputDialog.kt similarity index 85% rename from presentation/src/main/java/com/moez/QKSMS/common/widget/FieldDialog.kt rename to presentation/src/main/java/com/moez/QKSMS/common/widget/TextInputDialog.kt index 00240f02af62b1bfd7bdd297673d480d2c8b6b2c..1f639a018ad01a2da177dbcd77b47788606cf440 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/widget/FieldDialog.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/widget/TextInputDialog.kt @@ -23,11 +23,11 @@ import android.content.DialogInterface import android.view.LayoutInflater import androidx.appcompat.app.AlertDialog import com.moez.QKSMS.R -import kotlinx.android.synthetic.main.field_dialog.view.* +import kotlinx.android.synthetic.main.text_input_dialog.view.* -class FieldDialog(context: Activity, hint: String, listener: (String) -> Unit) : AlertDialog(context, R.style.customAlertDialog) { +class TextInputDialog(context: Activity, hint: String, listener: (String) -> Unit) : AlertDialog(context) { - private val layout = LayoutInflater.from(context).inflate(R.layout.field_dialog, null) + private val layout = LayoutInflater.from(context).inflate(R.layout.text_input_dialog, null) init { layout.field.hint = hint @@ -40,7 +40,7 @@ class FieldDialog(context: Activity, hint: String, listener: (String) -> Unit) : } } - fun setText(text: String): FieldDialog { + fun setText(text: String): TextInputDialog { layout.field.setText(text) return this } diff --git a/presentation/src/main/java/com/moez/QKSMS/common/widget/TightTextView.kt b/presentation/src/main/java/com/moez/QKSMS/common/widget/TightTextView.kt index 0c104b81c7a67f19b519898a619c3d93d9a7bed7..ce86aef7a5b278ee99d5eb2313b7bfe531006a19 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/widget/TightTextView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/widget/TightTextView.kt @@ -37,7 +37,7 @@ class TightTextView @JvmOverloads constructor( val maxLineWidth = (0 until layout.lineCount) .map(layout::getLineWidth) - .max() ?: 0f + .maxOrNull() ?: 0f val width = Math.ceil(maxLineWidth.toDouble()).toInt() + compoundPaddingLeft + compoundPaddingRight if (width < measuredWidth) { 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/backup/BackupPresenter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/backup/BackupPresenter.kt index f207eb9c704ff74bf82d132e1068f02b182856ed..8d945e2e663d3bc719437744e77add7b74ef7c2f 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/backup/BackupPresenter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/backup/BackupPresenter.kt @@ -62,7 +62,7 @@ class BackupPresenter @Inject constructor( .distinctUntilChanged() .switchMap { backupRepo.getBackups() } .doOnNext { backups -> newState { copy(backups = backups) } } - .map { backups -> backups.map { it.date }.max() ?: 0L } + .map { backups -> backups.map { it.date }.maxOrNull() ?: 0L } .map { lastBackup -> when (lastBackup) { 0L -> context.getString(R.string.backup_never) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/BlockingDialog.kt b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/BlockingDialog.kt index ef544f6266a0e168d0893ec812d57905b44f6389..d70162d1ae8e7d9525971d610410d2443a0184ea 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/BlockingDialog.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/BlockingDialog.kt @@ -77,7 +77,7 @@ class BlockingDialog @Inject constructor( } private fun allBlocked(addresses: List): Boolean = addresses.all { address -> - blockingManager.getAction(address).blockingGet() is BlockingClient.Action.Block + blockingManager.isBlacklisted(address).blockingGet() is BlockingClient.Action.Block } private suspend fun showDialog( @@ -92,8 +92,9 @@ class BlockingDialog @Inject constructor( } val manager = context.getString(when (prefs.blockingManager.get()) { - Preferences.BLOCKING_MANAGER_SIA -> R.string.blocking_manager_sia_title + Preferences.BLOCKING_MANAGER_CB -> R.string.blocking_manager_call_blocker_title Preferences.BLOCKING_MANAGER_CC -> R.string.blocking_manager_call_control_title + Preferences.BLOCKING_MANAGER_SIA -> R.string.blocking_manager_sia_title else -> R.string.blocking_manager_qksms_title }) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/BlockingPresenter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/BlockingPresenter.kt index 749ae671873974832cd25e877649f06e8b073636..4e37d238c2c5b7c090fc0e73ee1848d56e7e5d24 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/BlockingPresenter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/BlockingPresenter.kt @@ -38,8 +38,9 @@ class BlockingPresenter @Inject constructor( disposables += prefs.blockingManager.asObservable() .map { client -> when (client) { - Preferences.BLOCKING_MANAGER_SIA -> R.string.blocking_manager_sia_title + Preferences.BLOCKING_MANAGER_CB -> R.string.blocking_manager_call_blocker_title Preferences.BLOCKING_MANAGER_CC -> R.string.blocking_manager_call_control_title + Preferences.BLOCKING_MANAGER_SIA -> R.string.blocking_manager_sia_title else -> R.string.blocking_manager_qksms_title } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerController.kt b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerController.kt index a7d096208439fb9d7ffe49af34a0db6266d14b04..245d0fef3acba7c77cac50520135a742fe79edb3 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerController.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerController.kt @@ -2,11 +2,14 @@ package com.moez.QKSMS.feature.blocking.manager import android.app.Activity import android.app.AlertDialog +import android.content.res.ColorStateList import android.view.View -import androidx.core.view.isVisible +import androidx.core.view.isInvisible import com.jakewharton.rxbinding2.view.clicks import com.moez.QKSMS.R import com.moez.QKSMS.common.base.QkController +import com.moez.QKSMS.common.util.Colors +import com.moez.QKSMS.common.util.extensions.resolveThemeColor import com.moez.QKSMS.injection.appComponent import com.moez.QKSMS.util.Preferences import io.reactivex.Observable @@ -14,12 +17,12 @@ import io.reactivex.Single import io.reactivex.subjects.PublishSubject import kotlinx.android.synthetic.main.blocking_manager_controller.* import kotlinx.android.synthetic.main.blocking_manager_list_option.view.* -import kotlinx.android.synthetic.main.radio_preference_view.view.* import javax.inject.Inject class BlockingManagerController : QkController(), BlockingManagerView { + @Inject lateinit var colors: Colors @Inject override lateinit var presenter: BlockingManagerPresenter private val activityResumedSubject: PublishSubject = PublishSubject.create() @@ -35,6 +38,18 @@ class BlockingManagerController : QkController R.drawable.ic_chevron_right_black_24dp + else -> R.drawable.ic_check_white_24dp } override fun activityResumed(): Observable<*> = activityResumedSubject override fun qksmsClicked(): Observable<*> = qksms.clicks() + override fun callBlockerClicked(): Observable<*> = callBlocker.clicks() override fun callControlClicked(): Observable<*> = callControl.clicks() override fun siaClicked(): Observable<*> = shouldIAnswer.clicks() diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerPreferenceView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerPreferenceView.kt new file mode 100644 index 0000000000000000000000000000000000000000..8abce241a55305058a491a5f306a6060e6f2317f --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerPreferenceView.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2020 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.blocking.manager + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import com.moez.QKSMS.R +import com.moez.QKSMS.common.util.extensions.animateLayoutChanges +import com.moez.QKSMS.common.util.extensions.resolveThemeAttribute +import com.moez.QKSMS.common.util.extensions.setVisible +import kotlinx.android.synthetic.main.blocking_manager_preference_view.view.* + +class BlockingManagerPreferenceView @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null +) : ConstraintLayout(context, attrs) { + + var icon: Drawable? = null + set(value) { + field = value + + if (isInEditMode) { + findViewById(R.id.iconView).setImageDrawable(value) + } else { + iconView.setImageDrawable(value) + } + } + + var title: String? = null + set(value) { + field = value + + if (isInEditMode) { + findViewById(R.id.titleView).text = value + } else { + titleView.text = value + } + } + + var summary: String? = null + set(value) { + field = value + + if (isInEditMode) { + findViewById(R.id.summaryView).run { + text = value + setVisible(value?.isNotEmpty() == true) + } + } else { + summaryView.text = value + summaryView.setVisible(value?.isNotEmpty() == true) + } + } + + init { + View.inflate(context, R.layout.blocking_manager_preference_view, this) + setBackgroundResource(context.resolveThemeAttribute(R.attr.selectableItemBackground)) + + context.obtainStyledAttributes(attrs, R.styleable.BlockingManagerPreferenceView).run { + icon = getDrawable(R.styleable.BlockingManagerPreferenceView_icon) + title = getString(R.styleable.BlockingManagerPreferenceView_title) + summary = getString(R.styleable.BlockingManagerPreferenceView_summary) + + // If there's a custom view used for the preference's widget, inflate it + getResourceId(R.styleable.BlockingManagerPreferenceView_widget, -1).takeIf { it != -1 }?.let { id -> + View.inflate(context, id, widgetFrame) + } + + recycle() + } + } +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerPresenter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerPresenter.kt index 1ef5d91fc706362e3684e4582ab8bb6fca8e3020..71e66aa21e057d23af8d4686d661d36e600309e6 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerPresenter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerPresenter.kt @@ -3,6 +3,7 @@ package com.moez.QKSMS.feature.blocking.manager import android.content.Context import com.moez.QKSMS.R import com.moez.QKSMS.blocking.BlockingClient +import com.moez.QKSMS.blocking.CallBlockerBlockingClient import com.moez.QKSMS.blocking.CallControlBlockingClient import com.moez.QKSMS.blocking.QksmsBlockingClient import com.moez.QKSMS.blocking.ShouldIAnswerBlockingClient @@ -21,6 +22,7 @@ import javax.inject.Inject class BlockingManagerPresenter @Inject constructor( private val analytics: AnalyticsManager, + private val callBlocker: CallBlockerBlockingClient, private val callControl: CallControlBlockingClient, private val context: Context, private val conversationRepo: ConversationRepository, @@ -30,6 +32,7 @@ class BlockingManagerPresenter @Inject constructor( private val shouldIAnswer: ShouldIAnswerBlockingClient ) : QkPresenter(BlockingManagerState( blockingManager = prefs.blockingManager.get(), + callBlockerInstalled = callBlocker.isAvailable(), callControlInstalled = callControl.isAvailable(), siaInstalled = shouldIAnswer.isAvailable() )) { @@ -42,6 +45,12 @@ class BlockingManagerPresenter @Inject constructor( override fun bindIntents(view: BlockingManagerView) { super.bindIntents(view) + view.activityResumed() + .map { callBlocker.isAvailable() } + .distinctUntilChanged() + .autoDisposable(view.scope()) + .subscribe { available -> newState { copy(callBlockerInstalled = available) } } + view.activityResumed() .map { callControl.isAvailable() } .distinctUntilChanged() @@ -64,6 +73,23 @@ class BlockingManagerPresenter @Inject constructor( prefs.blockingManager.set(Preferences.BLOCKING_MANAGER_QKSMS) } + view.callBlockerClicked() + .filter { + val installed = callBlocker.isAvailable() + if (!installed) { + analytics.track("Install Call Blocker") + navigator.installCallBlocker() + } + + val enabled = prefs.blockingManager.get() == Preferences.BLOCKING_MANAGER_CB + installed && !enabled + } + .autoDisposable(view.scope()) + .subscribe { + analytics.setUserProperty("Blocking Manager", "Call Blocker") + prefs.blockingManager.set(Preferences.BLOCKING_MANAGER_CB) + } + view.callControlClicked() .filter { val installed = callControl.isAvailable() @@ -92,7 +118,7 @@ class BlockingManagerPresenter @Inject constructor( .switchMap { numbers -> callControl.block(numbers).andThen(Observable.just(Unit)) } // Hack .autoDisposable(view.scope()) .subscribe { - callControl.getAction("callcontrol").blockingGet() + callControl.shouldBlock("callcontrol").blockingGet() analytics.setUserProperty("Blocking Manager", "Call Control") prefs.blockingManager.set(Preferences.BLOCKING_MANAGER_CC) } @@ -117,6 +143,6 @@ class BlockingManagerPresenter @Inject constructor( private fun getAddressesToBlock(client: BlockingClient) = conversationRepo.getBlockedConversations() .fold(listOf(), { numbers, conversation -> numbers + conversation.recipients.map { it.address } }) - .filter { number -> client.getAction(number).blockingGet() !is BlockingClient.Action.Block } + .filter { number -> client.isBlacklisted(number).blockingGet() !is BlockingClient.Action.Block } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerState.kt index 2663caf067f54e643af3b439f60ff2fe97d3d369..169add4b2d279e515c97e60905f624acc832f09e 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerState.kt @@ -2,6 +2,7 @@ package com.moez.QKSMS.feature.blocking.manager data class BlockingManagerState( val blockingManager: Int = 0, + val callBlockerInstalled: Boolean = false, val callControlInstalled: Boolean = false, val siaInstalled: Boolean = false ) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerView.kt index 7318e53a212b6110a255c761d62ece63e571b82e..dd871e17d534ad9b1bc430fde3e7d1960f5448e3 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/blocking/manager/BlockingManagerView.kt @@ -8,6 +8,7 @@ interface BlockingManagerView : QkViewContract { fun activityResumed(): Observable<*> fun qksmsClicked(): Observable<*> + fun callBlockerClicked(): Observable<*> fun callControlClicked(): Observable<*> fun siaClicked(): Observable<*> 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/changelog/ChangelogAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/changelog/ChangelogAdapter.kt index 43de03391cd80d15bd54968639908b2920cfbe4e..efba6cfc239c91b6c128cabd7e2d3e44b7ac4a1c 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/changelog/ChangelogAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/changelog/ChangelogAdapter.kt @@ -32,21 +32,21 @@ class ChangelogAdapter(private val context: Context) : QkAdapter() if (changelog.added.isNotEmpty()) { changes += ChangelogItem(0, context.getString(R.string.changelog_added)) - changes += changelog.added.map { change -> ChangelogItem(1, change) } + changes += changelog.added.map { change -> ChangelogItem(1, "• $change") } changes += ChangelogItem(0, "") } if (changelog.improved.isNotEmpty()) { changes += ChangelogItem(0, context.getString(R.string.changelog_improved)) - changes += changelog.improved.map { change -> ChangelogItem(1, change) } + changes += changelog.improved.map { change -> ChangelogItem(1, "• $change") } changes += ChangelogItem(0, "") } if (changelog.fixed.isNotEmpty()) { changes += ChangelogItem(0, context.getString(R.string.changelog_fixed)) - changes += changelog.fixed.map { change -> ChangelogItem(1, change) } + changes += changelog.fixed.map { change -> ChangelogItem(1, "• $change") } changes += ChangelogItem(0, "") } data = changes diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/changelog/ChangelogDialog.kt b/presentation/src/main/java/com/moez/QKSMS/feature/changelog/ChangelogDialog.kt index e888acb39a97a888ffcb195794f8956ebf72431c..27db3757e1bf7e6df952041886d404a0966a0a0e 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/changelog/ChangelogDialog.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/changelog/ChangelogDialog.kt @@ -49,7 +49,7 @@ class ChangelogDialog(activity: MainActivity) { layout.dismiss.setOnClickListener { dialog.dismiss() } } - fun show(changelog: ChangelogManager.Changelog) { + fun show(changelog: ChangelogManager.CumulativeChangelog) { adapter.setChangelog(changelog) dialog.show() } 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..77adf60117bf92ba001bb2462a5bd417ce903dcb 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 @@ -22,10 +22,12 @@ import android.content.Context import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup +import androidx.core.view.isVisible import com.bumptech.glide.Glide 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.getDisplayName import com.moez.QKSMS.extensions.mapNotNull import com.moez.QKSMS.model.Attachment import ezvcard.Ezvcard @@ -34,7 +36,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 @@ -75,13 +78,17 @@ class AttachmentAdapter @Inject constructor( 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() } + .map { vcard -> vcard.getDisplayName() ?: "" } .subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe( { vcard -> view.name?.text = vcard.formattedName.value }, { throwable -> Log.i("AttachmentAdapter.kt", "Name field is null") } ) + .subscribe { displayName -> + holder.name?.text = displayName + holder.name?.isVisible = displayName.isNotEmpty() + } } } @@ -90,4 +97,4 @@ class AttachmentAdapter @Inject constructor( is Attachment.Contact -> VIEW_TYPE_CONTACT } -} +} \ No newline at end of file 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 5b8c309806a5a6cd760e35f76afdee9a9c158264..267194928ace54d25145ad03817e21b9bb8adfb1 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 @@ -28,13 +28,9 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.Handler import android.provider.ContactsContract import android.provider.MediaStore import android.text.format.DateFormat -import android.util.Log -import android.util.TypedValue -import android.view.ContextThemeWrapper import android.view.Menu import android.view.MenuItem import androidx.appcompat.app.AlertDialog @@ -51,14 +47,18 @@ 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.dismissKeyboard +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 @@ -69,36 +69,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 + @Inject lateinit var attachmentAdapter: AttachmentAdapter + @Inject lateinit var chipsAdapter: ChipsAdapter + @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() } @@ -118,7 +111,6 @@ class ComposeActivity : QkThemedActivity(), ComposeView { override val inputContentIntent by lazy { message.inputContentSelected } override val scheduleSelectedIntent: Subject = PublishSubject.create() override val changeSimIntent by lazy { sim.clicks() } - override val selectPreferredSIM by lazy { viewSelectPreferredSim.clicks() } override val scheduleCancelIntent by lazy { scheduledCancel.clicks() } override val sendIntent by lazy { send.clicks() } override val viewQksmsPlusIntent: Subject = PublishSubject.create() @@ -141,7 +133,6 @@ class ComposeActivity : QkThemedActivity(), ComposeView { chipsAdapter.view = chips - contacts.itemAnimator = null chips.itemAnimator = null chips.layoutManager = FlexboxLayoutManager(this) @@ -155,38 +146,22 @@ class ComposeActivity : QkThemedActivity(), ComposeView { message.supportsInputContent = true - loading.setTint(getColor(R.color.tools_theme)) - attach.setBackgroundTint(getColor(R.color.tools_theme)) - send.setTint(getColor(R.color.tools_theme)) + theme + .doOnNext { loading.setTint(it.theme) } + .doOnNext { attach.setBackgroundTint(it.theme) } + .doOnNext { attach.setTint(it.textPrimary) } + .doOnNext { messageAdapter.theme = it } + .autoDisposable(scope()) + .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() { - super.onResume() - - Handler().postDelayed({ - //call selectPreferredSIM in ComposeViewModel.kt - viewSelectPreferredSim.performClick(); - }, 600) - } - - override fun onPostResume() { - super.onPostResume() - - Handler().postDelayed({ - //call selectPreferredSIM in ComposeViewModel.kt - viewSelectPreferredSim.performClick(); - }, 2000) - } - - override fun onStart() { super.onStart() activityVisibleIntent.onNext(true) @@ -203,7 +178,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) @@ -212,20 +187,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 @@ -233,19 +210,14 @@ 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) + messageList.setVisible(!state.editingMode || state.sendAsGroup || state.selectedChips.size == 1) messageAdapter.data = state.messages messageAdapter.highlight = state.searchSelectionId @@ -255,7 +227,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 @@ -263,7 +235,7 @@ class ComposeActivity : QkThemedActivity(), ComposeView { sim.setVisible(state.subscription != null) sim.contentDescription = getString(R.string.compose_sim_cd, state.subscription?.displayName) - simIndex.text = "${state.subscription?.simSlotIndex?.plus(1)}" + simIndex.text = state.subscription?.simSlotIndex?.plus(1)?.toString() send.isEnabled = state.canSend send.imageAlpha = if (state.canSend) 255 else 128 @@ -303,15 +275,38 @@ 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() + + // On some devices, the keyboard can cover the date picker + message.hideKeyboard() } override fun requestContact() { 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() { @@ -321,20 +316,23 @@ 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) + override fun setDraft(draft: String) { + message.setText(draft) + message.setSelection(draft.length) + } override fun scrollToMessage(id: Long) { messageAdapter.data?.second @@ -346,7 +344,7 @@ class ComposeActivity : QkThemedActivity(), ComposeView { override fun showQksmsPlusSnackbar(message: Int) { Snackbar.make(contentView, message, Snackbar.LENGTH_LONG).run { setAction(R.string.button_more) { viewQksmsPlusIntent.onNext(Unit) } - setActionTextColor(getColor(R.color.tools_theme)) + setActionTextColor(colors.theme().theme) show() } } @@ -366,15 +364,34 @@ 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 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..8368dfa81f53d8f2b5bcccb2c5c9c89ec48572f3 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 @@ -21,6 +21,7 @@ package com.moez.QKSMS.feature.compose import android.content.Intent import android.net.Uri import androidx.lifecycle.ViewModel +import com.google.android.mms.ContentType import com.moez.QKSMS.injection.ViewModelKey import com.moez.QKSMS.model.Attachment import com.moez.QKSMS.model.Attachments @@ -28,6 +29,7 @@ import dagger.Module import dagger.Provides import dagger.multibindings.IntoMap import java.net.URLDecoder +import java.nio.charset.Charset import javax.inject.Named @Module @@ -42,42 +44,56 @@ 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 + ?.substringBefore("?") // Remove query + ?.split(",", ";") + ?.filter { number -> number.isNotEmpty() } + ?: listOf() } @Provides @Named("text") fun provideSharedText(activity: ComposeActivity): String { - return activity.intent.extras?.getString(Intent.EXTRA_TEXT) + var subject = activity.intent.getStringExtra(Intent.EXTRA_SUBJECT) ?: ""; + if (subject != "") { + subject += "\n" + } + + return subject + (activity.intent.extras?.getString(Intent.EXTRA_TEXT) ?: activity.intent.extras?.getString("sms_body") - ?: "" + ?: activity.intent?.decodedDataString() + ?.substringAfter('?') // Query string + ?.takeIf { it.startsWith("body") } + ?.substringAfter('=') + ?: "") } @Provides @Named("attachments") fun provideSharedAttachments(activity: ComposeActivity): Attachments { - val sharedImages = mutableListOf() - activity.intent.getParcelableExtra(Intent.EXTRA_STREAM)?.run(sharedImages::add) - activity.intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.run(sharedImages::addAll) - return Attachments(sharedImages.map { Attachment.Image(it) }) + val uris = mutableListOf() + activity.intent.getParcelableExtra(Intent.EXTRA_STREAM)?.run(uris::add) + activity.intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.run(uris::addAll) + return Attachments(uris.mapNotNull { uri -> + val mimeType = activity.contentResolver.getType(uri) + when { + ContentType.isImageType(mimeType) -> { + Attachment.Image(uri) + } + + ContentType.TEXT_VCARD.equals(mimeType, true) -> { + val inputStream = activity.contentResolver.openInputStream(uri) + val text = inputStream?.reader(Charset.forName("utf-8"))?.readText() + text?.let(Attachment::Contact) + } + + else -> null + } + }) } @Provides @@ -85,4 +101,13 @@ class ComposeActivityModule { @ViewModelKey(ComposeViewModel::class) fun provideComposeViewModel(viewModel: ComposeViewModel): ViewModel = viewModel -} \ No newline at end of file + // 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 + } + +} 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..d8a78eec2c88beab77565bb3b5cd1efa0d615f4f 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<*> @@ -55,7 +52,6 @@ interface ComposeView : QkView { val scheduleSelectedIntent: Observable val scheduleCancelIntent: Observable<*> val changeSimIntent: Observable<*> - val selectPreferredSIM: Observable<*> val sendIntent: Observable val viewQksmsPlusIntent: Subject val backPressedIntent: Observable @@ -65,6 +61,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..28ab8e4e5ffead2671ce1ead2f4b81cbaaa718cc 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 @@ -20,13 +20,10 @@ package com.moez.QKSMS.feature.compose import android.content.Context import android.net.Uri -import android.os.Build +import android.os.Vibrator import android.provider.ContactsContract -import android.telephony.SmsManager import android.telephony.SmsMessage -import android.util.Log -import android.view.inputmethod.EditorInfo -import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService import com.moez.QKSMS.R import com.moez.QKSMS.common.Navigator import com.moez.QKSMS.common.base.QkViewModel @@ -39,12 +36,20 @@ 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.BillingManager 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,51 +68,50 @@ 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 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("text") private val sharedText: String, - @Named("attachments") private val sharedAttachments: Attachments, - private val context: Context, - private val activeConversationManager: ActiveConversationManager, - private val addScheduledMessage: AddScheduledMessage, - private val cancelMessage: CancelDelayedMessage, - private val contactFilter: ContactFilter, - private val contactsRepo: ContactRepository, - private val conversationRepo: ConversationRepository, - private val deleteMessages: DeleteMessages, - private val markRead: MarkRead, - private val messageDetailsFormatter: MessageDetailsFormatter, - private val messageRepo: MessageRepository, - private val navigator: Navigator, - private val permissionManager: PermissionManager, - private val phoneNumberUtils: PhoneNumberUtils, - private val prefs: Preferences, - private val retrySending: RetrySending, - private val sendMessage: SendMessage, - private val subscriptionManager: SubscriptionManagerCompat, - private val syncContacts: ContactSync + @Named("query") private val query: String, + @Named("threadId") private val threadId: Long, + @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 conversationRepo: ConversationRepository, + private val deleteMessages: DeleteMessages, + private val markRead: MarkRead, + private val messageDetailsFormatter: MessageDetailsFormatter, + private val messageRepo: MessageRepository, + private val navigator: Navigator, + private val permissionManager: PermissionManager, + private val phoneNumberUtils: PhoneNumberUtils, + private val prefs: Preferences, + private val retrySending: RetrySending, + private val sendMessage: SendMessage, + 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 +119,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 +162,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() } @@ -186,6 +190,10 @@ class ComposeViewModel @Inject constructor( .distinctUntilChanged() .subscribe { title -> newState { copy(conversationtitle = title) } } + disposables += prefs.sendAsGroup.asObservable() + .distinctUntilChanged() + .subscribe { enabled -> newState { copy(sendAsGroup = enabled) } } + disposables += attachments .subscribe { attachments -> newState { copy(attachments = attachments) } } @@ -217,92 +225,69 @@ 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() } @@ -401,7 +395,7 @@ class ComposeViewModel @Inject constructor( // Toggle the group sending mode view.sendAsGroupIntent .autoDisposable(view.scope()) - .subscribe { newState { copy(sendAsGroup = !sendAsGroup) } } + .subscribe { prefs.sendAsGroup.set(!prefs.sendAsGroup.get()) } // Scroll to search position searchSelection @@ -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) @@ -449,24 +450,27 @@ class ComposeViewModel @Inject constructor( .mapNotNull(messageRepo::getMessage) .doOnNext { message -> view.setDraft(message.getText()) } .autoDisposable(view.scope()) - .subscribe { message -> cancelMessage.execute(message.id) } + .subscribe { message -> + cancelMessage.execute(CancelDelayedMessage.Params(message.id, message.threadId)) + } // 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() @@ -508,6 +512,10 @@ class ComposeViewModel @Inject constructor( // Choose a time to schedule the message view.scheduleIntent .doOnNext { newState { copy(attaching = false) } } + .withLatestFrom(billingManager.upgradeStatus) { _, upgraded -> upgraded } + .filter { upgraded -> + upgraded.also { if (!upgraded) view.showQksmsPlusSnackbar(R.string.compose_scheduled_plus) } + } .autoDisposable(view.scope()) .subscribe { view.requestDatePicker() } @@ -516,7 +524,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) } } @@ -612,22 +620,14 @@ class ComposeViewModel @Inject constructor( subIndex < subs.size - 1 -> subs[subIndex + 1] else -> subs[0] } - newState { copy(subscription = subscription) } - } - .autoDisposable(view.scope()) - .subscribe() - //Select preferred sim for send sms in setting - view.selectPreferredSIM - .withLatestFrom(state) { _, state -> - val subs = subscriptionManager.activeSubscriptionInfoList - val smsManager: SmsManager = SmsManager.getDefault() - for (i in subs.indices) { - if (subs[i].subscriptionId == smsManager.subscriptionId) { - newState { copy(subscription = subs[i]) } - break - } + if (subscription != null) { + context.getSystemService()?.vibrate(40) + context.makeToast(context.getString(R.string.compose_sim_changed_toast, + subscription.simSlotIndex + 1, subscription.displayName)) } + + newState { copy(subscription = subscription) } } .autoDisposable(view.scope()) .subscribe() @@ -638,12 +638,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 @@ -651,6 +651,7 @@ class ComposeViewModel @Inject constructor( Preferences.SEND_DELAY_LONG -> 10000 else -> 0 } + val sendAsGroup = !state.editingMode || state.sendAsGroup when { // Scheduling a message @@ -661,13 +662,13 @@ class ComposeViewModel @Inject constructor( .map { it.getUri() } .map { it.toString() } val params = AddScheduledMessage - .Params(state.scheduled, subId, addresses, state.sendAsGroup, body, uris) + .Params(state.scheduled, subId, addresses, sendAsGroup, body, uris) addScheduledMessage.execute(params) context.makeToast(R.string.compose_scheduled_toast) } // Sending a group message - state.sendAsGroup -> { + sendAsGroup -> { sendMessage.execute(SendMessage .Params(subId, conversation.id, addresses, body, attachments, delay)) } @@ -702,12 +703,17 @@ class ComposeViewModel @Inject constructor( this.attachments.onNext(ArrayList()) if (state.editingMode) { - newState { copy(editingMode = false, sendAsGroup = true, hasError = !state.sendAsGroup) } + newState { copy(editingMode = false, hasError = !sendAsGroup) } } } .autoDisposable(view.scope()) .subscribe() + // View QKSMS+ + view.viewQksmsPlusIntent + .autoDisposable(view.scope()) + .subscribe { navigator.showQksmsPlusActivity("compose_schedule") } + // Navigate back view.optionsItemIntent .filter { it == android.R.id.home } 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..fbfc33b3efb6863ce4573b408271fbe1066b211a 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) @@ -79,7 +83,7 @@ class ComposeWindowCallback(private val localCallback: Window.Callback, private return localCallback.onCreatePanelMenu(i, menu) } - override fun onPreparePanel(i: Int, view: View, menu: Menu): Boolean { + override fun onPreparePanel(i: Int, view: View?, menu: Menu): Boolean { return localCallback.onPreparePanel(i, view, menu) } @@ -140,4 +144,4 @@ class ComposeWindowCallback(private val localCallback: Window.Callback, private override fun onActionModeFinished(actionMode: ActionMode) { localCallback.onActionModeFinished(actionMode) } -} +} \ No newline at end of file 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 db5779e072725ca7f7acd8d63635f4309dec6a95..b95d7a9dbecdaf2b6dcaa8848df4dcdff34bde77 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 @@ -26,8 +26,6 @@ import android.text.Layout import android.text.Spannable import android.text.SpannableString import android.text.style.StyleSpan -import android.util.TypedValue -import android.view.ContextThemeWrapper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -62,7 +60,14 @@ 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 @@ -126,6 +131,8 @@ class MessagesAdapter @Inject constructor( private val partsViewPool = RecyclerView.RecycledViewPool() private val subs = subscriptionManager.activeSubscriptionInfoList + var theme: Colors.Theme = colors.theme() + /** * If the viewType is negative, then the viewHolder has an attachment. We'll consider * this a unique viewType even though it uses the same view, so that regular messages @@ -139,13 +146,10 @@ class MessagesAdapter @Inject constructor( if (viewType == VIEW_TYPE_MESSAGE_OUT) { view = layoutInflater.inflate(R.layout.message_list_item_out, parent, false) - view.findViewById(R.id.cancelIcon).setTint(context.getColor(R.color.tools_theme)) - view.findViewById(R.id.cancel).setTint(context.getColor(R.color.tools_theme)) + view.findViewById(R.id.cancelIcon).setTint(theme.theme) + 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(context.getColor(R.color.white)) - view.body.setBackgroundTint(context.getColor(R.color.tools_theme)) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -179,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) } @@ -211,34 +219,32 @@ 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 + val timeSincePrevious = TimeUnit.MILLISECONDS.toMinutes(message.date - (previous?.date ?: 0)) + val subscription = subs.find { sub -> sub.subscriptionId == message.subId } + + holder.timestamp.text = dateFormatter.getMessageTimestamp(message.date) + holder.simIndex.text = subscription?.simSlotIndex?.plus(1)?.toString() - view.timestamp.text = dateFormatter.getMessageTimestamp(message.date) - view.simIndex.text = "${simIndex + 1}" + holder.timestamp.setVisible(timeSincePrevious >= BubbleUtils.TIMESTAMP_THRESHOLD + || message.subId != previous?.subId && subscription != null) - view.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 && subscription != null && subs.size > 1) + holder.simIndex.setVisible(message.subId != previous?.subId && subscription != null && subs.size > 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) - view.avatar.setBackgroundTint(context.getColor(R.color.tools_theme)) - view.body.setTextColor(context.getColor(R.color.white)) - view.body.setBackgroundTint(context.getColor(R.color.tools_theme)) + 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 @@ -264,30 +270,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 - partsAdapter.setData(message, previous, next, view) + val partsAdapter = holder.attachments.adapter as PartsAdapter + partsAdapter.theme = theme + 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)) @@ -301,7 +306,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 @@ -335,5 +340,4 @@ class MessagesAdapter @Inject constructor( } } - } \ No newline at end of file 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..2681d520b43b9cd7753a830123e551459d3654fc --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/ComposeItemAdapter.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.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.subtitle.collapseEnabled = conversation.recipients.size > 1 + + 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.subtitle.collapseEnabled = group.contacts.size > 1 + + 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 72% 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 13ccdb9d58c4cee9d67498c2cd2d33dc70523a2e..74448bedd0edf5e323ab5017d4d9d8d8ab655bbe 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,15 +27,13 @@ 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 + @Inject lateinit var colors: Colors init { View.inflate(context, R.layout.contact_chip_detailed, this) @@ -47,18 +45,19 @@ class DetailedChipView(context: Context) : RelativeLayout(context) { isFocusable = true isFocusableInTouchMode = true - - card.setBackgroundTint(context.getColor(R.color.tools_theme)) - name.setTextColor(context.getColor(R.color.textPrimary)) - info.setTextColor(context.getColor(R.color.textTertiary)) - delete.setTint(context.getColor(R.color.textPrimary)) - } - fun setContact(contact: Contact) { - avatar.setContact(contact) - name.text = contact.name - info.text = contact.numbers.joinToString(", ") { it.address } + fun setRecipient(recipient: Recipient) { + avatar.setRecipient(recipient) + name.text = recipient.contact?.name?.takeIf { it.isNotBlank() } ?: recipient.address + info.text = recipient.address + + colors.theme(recipient).let { theme -> + card.setBackgroundTint(context.getColor(R.color.tools_theme)) + name.setTextColor(context.getColor(R.color.textPrimary)) + info.setTextColor(context.getColor(R.color.textTertiary)) + delete.setTint(context.getColor(R.color.textPrimary)) + } } fun show() { diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/PhoneNumberAction.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/PhoneNumberAction.kt new file mode 100644 index 0000000000000000000000000000000000000000..7464cefd306b82b7aec3f5955a72b51f82d2ee3f --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/PhoneNumberAction.kt @@ -0,0 +1,25 @@ +/* + * 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 + +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..084e41a37d2d2d5d293481e7c559a46e7d0a7c91 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/editing/PhoneNumberPickerAdapter.kt @@ -0,0 +1,80 @@ +/* + * 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.common.util.extensions.forwardTouches +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.* +import kotlinx.android.synthetic.main.radio_preference_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 { + radioButton.forwardTouches(itemView) + + view.setOnClickListener { + val phoneNumber = getItem(adapterPosition) + selectedItem = phoneNumber.id + } + } + } + + override fun onBindViewHolder(holder: QkViewHolder, position: Int) { + val phoneNumber = getItem(position) + + holder.number.radioButton.isChecked = phoneNumber.id == selectedItem + holder.number.titleView.text = phoneNumber.address + holder.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 20c9d65f7ae577831ede34eda9070a82cf443a7f..15ea1091d4933c321b4fb1886dcabe3bbc99bf28 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 @@ -24,6 +24,7 @@ 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,28 +35,29 @@ 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() { override val partLayout = R.layout.mms_file_list_item + override var theme = colors.theme() // This is the last binder we check. If we're here, we can bind the part override fun canBindPart(part: MmsPart) = true @SuppressLint("CheckResult") override fun bindPart( - view: View, - part: MmsPart, - message: Message, - canGroupWithPrevious: Boolean, - canGroupWithNext: Boolean + 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) @@ -70,23 +72,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(context.getColor(R.color.tools_theme)) - view.icon.setTint(context.getColor(R.color.white)) - view.filename.setTextColor(context.getColor(R.color.textPrimary)) - view.size.setTextColor(context.getColor(R.color.textPrimary)) + 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 99d3dbbff818b5e7a5a21846f9ee4432e8869ab6..2f5fa2ffb90f607831bb7d67acb1613620bbc9d3 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,33 +29,34 @@ 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() { override val partLayout = R.layout.mms_preview_list_item + override var theme = colors.theme() override fun canBindPart(part: MmsPart) = part.isImage() || part.isVideo() override fun bindPart( - view: View, - part: MmsPart, - message: Message, - canGroupWithPrevious: Boolean, - canGroupWithNext: Boolean + 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 eaa9d11c098377c3752cf44018cdac9d52b5b7f5..4ba23dfbaf0f62ff3d11b34fbe3484b2d5166880 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 @@ -31,15 +31,16 @@ abstract class PartBinder { abstract val partLayout: Int + abstract var theme: Colors.Theme abstract fun canBindPart(part: MmsPart): Boolean abstract fun bindPart( - view: View, - part: MmsPart, - message: Message, - canGroupWithPrevious: Boolean, - canGroupWithNext: Boolean + holder: QkViewHolder, + part: MmsPart, + message: Message, + canGroupWithPrevious: Boolean, + canGroupWithNext: 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 17918d74941b7258205a7b8b001c3dd382378a51..72d0a9bf0e52ef20a1788ae998ab406a95d81b1f 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,52 +31,57 @@ 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( - colors: Colors, - fileBinder: FileBinder, - mediaBinder: MediaBinder, - vCardBinder: VCardBinder + colors: Colors, + fileBinder: FileBinder, + mediaBinder: MediaBinder, + vCardBinder: VCardBinder ) : QkAdapter() { private val partBinders = listOf(mediaBinder, vCardBinder, fileBinder) + var theme: Colors.Theme = colors.theme() + set(value) { + field = value + partBinders.forEach { binder -> binder.theme = value } + } + val clicks: Observable = Observable.merge(partBinders.map { it.clicks }) 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 32aa099b86af02b27f477b23f40b27bcf4fd5644..165aa9a10a3171338e058c9daac639e5643416aa 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 @@ -23,8 +23,11 @@ import android.util.Log import android.view.Gravity import android.view.View import android.widget.FrameLayout +import androidx.core.view.isVisible 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.getDisplayName import com.moez.QKSMS.common.util.extensions.resolveThemeColor import com.moez.QKSMS.common.util.extensions.setBackgroundTint import com.moez.QKSMS.common.util.extensions.setTint @@ -37,48 +40,53 @@ 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() { override val partLayout = R.layout.mms_vcard_list_item + override var theme = colors.theme() override fun canBindPart(part: MmsPart) = part.isVCard() override fun bindPart( - view: View, - part: MmsPart, - message: Message, - canGroupWithPrevious: Boolean, - canGroupWithNext: Boolean + 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() } } + .map { vcard -> vcard.getDisplayName() ?: "" } .subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ vcard -> view.name?.text = vcard.formattedName.value }, { throwable -> Log.i("VCardBinder.kt", "Name field is null") }) + .subscribe { displayName -> + holder.name?.text = displayName + holder.name.isVisible = displayName.isNotEmpty() + } - 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(context.getColor(R.color.tools_theme)) - view.vCardAvatar.setTint(context.getColor(R.color.textPrimary)) - view.name.setTextColor(context.getColor(R.color.textPrimary)) - view.label.setTextColor(context.getColor(R.color.textPrimary)) + 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)) } } -} +} \ No newline at end of file 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..e9960816d7818c0b0109fb25818f3f6fb7caed33 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/contacts/ContactsViewModel.kt @@ -0,0 +1,216 @@ +/* + * 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.android.schedulers.AndroidSchedulers +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()) + .map { (composeItem, force) -> + 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@map hashMapOf() + } + }) + } + .filter { result -> result.isNotEmpty() } + .observeOn(AndroidSchedulers.mainThread()) + .autoDisposable(view.scope()) + .subscribe { result -> view.finish(result) } + } + +} 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 100fd5e395cd2f726641b372437ad670e81c0e67..2c6ca7388d10123b0e16fe4f0abdf9fb5fc90347 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,6 +20,7 @@ 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 @@ -28,10 +29,10 @@ 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.common.widget.TextInputDialog import com.moez.QKSMS.feature.blocking.BlockingDialog import com.moez.QKSMS.feature.conversationinfo.injection.ConversationInfoModule +import com.moez.QKSMS.feature.themepicker.ThemePickerController import com.moez.QKSMS.injection.appComponent import com.uber.autodispose.android.lifecycle.scope import com.uber.autodispose.autoDisposable @@ -42,24 +43,16 @@ import kotlinx.android.synthetic.main.conversation_info_controller.* import javax.inject.Inject class ConversationInfoController( - val threadId: Long = 0 + val threadId: Long = 0 ) : QkController(), ConversationInfoView { - @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 - - private val nameDialog: FieldDialog by lazy { - FieldDialog(activity!!, activity!!.getString(R.string.info_name), nameChangeSubject::onNext) + @Inject override lateinit var presenter: ConversationInfoPresenter + @Inject lateinit var blockingDialog: BlockingDialog + @Inject lateinit var navigator: Navigator + @Inject lateinit var adapter: ConversationInfoAdapter + + private val nameDialog: TextInputDialog by lazy { + TextInputDialog(activity!!, activity!!.getString(R.string.info_name), nameChangeSubject::onNext) } private val nameChangeSubject: Subject = PublishSubject.create() @@ -76,13 +69,17 @@ class ConversationInfoController( } override fun onViewCreated() { - items.postDelayed({ items?.animateLayoutChanges = true }, 100) - - recipients.adapter = recipientAdapter + 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 + } + } - media.adapter = mediaAdapter - media.addItemDecoration(itemDecoration) - recipients?.scrapViews() + themedActivity?.theme + ?.autoDisposable(scope()) + ?.subscribe { recyclerView.scrapViews() } } override fun onAttach(view: View) { @@ -92,53 +89,35 @@ 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 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 + adapter.data = state.data + } - archive.isEnabled = !state.blocked - archive.title = activity?.getString(when (state.archived) { - true -> R.string.info_unarchive - false -> R.string.info_archive - }) + 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 - block.title = activity?.getString(when (state.blocked) { - true -> R.string.info_unblock - false -> R.string.info_block - }) + override fun showNameDialog(name: String) = nameDialog.setText(name).show() - mediaAdapter.updateData(state.media) + override fun showThemePicker(recipientId: Long) { + router.pushController(RouterTransaction.with(ThemePickerController(recipientId)) + .pushChangeHandler(QkChangeHandler()) + .popChangeHandler(QkChangeHandler())) } - override fun showNameDialog(name: String) = nameDialog.setText(name).show() - override fun showBlockingDialog(conversations: List, block: Boolean) { blockingDialog.show(activity!!, conversations, block) } 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 3cb72d5455c19902fe473d7821fe32177de8cdb9..d5e232bacfb56a540271bb41631e18c1f981fa86 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,11 +18,17 @@ */ 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 @@ -33,7 +39,8 @@ 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 +51,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 +59,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 +81,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 +112,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,8 +156,6 @@ class ConversationInfoPresenter @Inject constructor( .autoDisposable(view.scope()) .subscribe { conversation -> navigator.showNotificationSettings(conversation.id) } - // Show the theme settings for the conversation - // Toggle the archived state of the conversation view.archiveClicks() .withLatestFrom(conversation) { _, conversation -> conversation } @@ -170,6 +184,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 f311b7cb3a883f111fce9d9092263a3ea9924fb6..08a43f0fe4299ec68afec584469c62cc958679e7 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,6 +24,8 @@ 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<*> @@ -31,10 +33,12 @@ interface ConversationInfoView : QkViewContract { fun blockClicks(): Observable<*> fun deleteClicks(): Observable<*> fun confirmDelete(): Observable<*> + fun mediaClicks(): Observable fun showNameDialog(name: String) + fun showThemePicker(recipientId: Long) fun showBlockingDialog(conversations: List, block: Boolean) fun requestDefaultSms() fun showDeleteDialog() -} +} \ No newline at end of file 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/ConversationItemTouchCallback.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationItemTouchCallback.kt index 40c481ba3186462d497ad9c5570c3dbe9a9f009b..294216423c932c35c55b83f203d44d74ff8ebb86 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationItemTouchCallback.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationItemTouchCallback.kt @@ -138,15 +138,16 @@ class ConversationItemTouchCallback @Inject constructor( // This will trigger the animation back to neutral state val action = if (direction == ItemTouchHelper.RIGHT) rightAction else leftAction - if (action != Preferences.SWIPE_ACTION_ARCHIVE && action != Preferences.SWIPE_ACTION_READ) { + if (action != Preferences.SWIPE_ACTION_ARCHIVE) { adapter?.notifyItemChanged(viewHolder.adapterPosition) } } private fun iconForAction(action: Int, tint: Int): Bitmap? { val res = when (action) { - Preferences.SWIPE_ACTION_ARCHIVE -> R.drawable.ic_archive_black_24dp + Preferences.SWIPE_ACTION_ARCHIVE -> R.drawable.ic_archive_white_24dp Preferences.SWIPE_ACTION_DELETE -> R.drawable.ic_delete_white_24dp + Preferences.SWIPE_ACTION_BLOCK -> R.drawable.ic_block_white_24dp Preferences.SWIPE_ACTION_CALL -> R.drawable.ic_call_white_24dp Preferences.SWIPE_ACTION_READ -> R.drawable.ic_check_white_24dp Preferences.SWIPE_ACTION_UNREAD -> R.drawable.ic_markunread_black_24dp 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 21c03d8b365ee59fe37901d51f404f0cdf09fc43..818a5b6a92617c670a5e0686e90c5aeb923f5573 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 @@ -22,6 +22,10 @@ import android.content.Context import android.graphics.Typeface import android.view.LayoutInflater import android.view.ViewGroup +import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import androidx.core.text.color +import androidx.core.view.isInvisible import androidx.core.view.isVisible import com.moez.QKSMS.R import com.moez.QKSMS.common.Navigator @@ -32,6 +36,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 +45,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) } @@ -83,29 +91,44 @@ 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) + // 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) + } + } + val theme = colors.theme(recipient).theme + + 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 { - conversation.draft.isNotEmpty() -> context.getString(R.string.main_draft, conversation.draft) + holder.avatars.recipients = conversation.recipients + holder.title.collapseEnabled = conversation.recipients.size > 1 + holder.title.text = buildSpannedString { + append(conversation.getTitle()) + if (conversation.draft.isNotEmpty()) { + color(theme) { append(" " + context.getString(R.string.main_draft)) } + } + } + holder.date.text = conversation.date.takeIf { it > 0 }?.let(dateFormatter::getConversationTimestamp) + holder.snippet.text = when { + conversation.draft.isNotEmpty() -> 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 + holder.unread.setTint(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..20fb0a7cb5690345904c434e53ad647216534499 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 @@ -36,12 +37,13 @@ import javax.inject.Inject import javax.inject.Named class GalleryViewModel @Inject constructor( - conversationRepo: ConversationRepository, - messageRepo: MessageRepository, - @Named("partId") private val partId: Long, - private val context: Context, - private val saveImage: SaveImage, - private val permissionManager: PermissionManager + conversationRepo: ConversationRepository, + @Named("partId") private val partId: Long, + private val context: Context, + private val messageRepo: MessageRepository, + private val navigator: Navigator, + private val saveImage: SaveImage, + private val permissions: PermissionManager ) : QkViewModel(GalleryState()) { init { @@ -59,13 +61,7 @@ class GalleryViewModel @Inject constructor( override fun bindView(view: GalleryView) { super.bindView(view) - view.permissionResult() - .map { permissionManager.hasStorage() } - .autoDisposable(view.scope()) - .subscribe{ - if(permissionManager.hasStorage()) - saveImage.execute(partId) { context.makeToast(R.string.gallery_toast_saved) } - } + // When the screen is touched, toggle the visibility of the navigation UI view.screenTouched() .withLatestFrom(state) { _, state -> state.navigationVisible } @@ -76,16 +72,18 @@ 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()) - saveImage.execute(partId) { context.makeToast(R.string.gallery_toast_saved) } - else - view.requestStoragePermission() - } + .subscribe { partId -> saveImage.execute(partId) { context.makeToast(R.string.gallery_toast_saved) } } + + // 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 f04b789fa3d8ef085669c18e57da765512153863..791b634ae2abe6ba61a1e917a232c09a24cca681 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,7 +23,7 @@ import android.animation.ObjectAnimator import android.app.AlertDialog import android.content.Intent import android.content.res.ColorStateList -import android.graphics.PorterDuff +import android.os.Build import android.os.Bundle import android.view.Gravity import android.view.Menu @@ -34,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 @@ -73,32 +73,17 @@ import javax.inject.Inject class MainActivity : QkThemedActivity(), MainView { - @Inject - lateinit var blockingDialog: BlockingDialog - - @Inject - lateinit var disposables: CompositeDisposable - - @Inject - lateinit var navigator: Navigator - - @Inject - lateinit var conversationsAdapter: ConversationsAdapter - - @Inject - lateinit var drawerBadgesExperiment: DrawerBadgesExperiment - - @Inject - lateinit var searchAdapter: SearchAdapter - - @Inject - lateinit var itemTouchCallback: ConversationItemTouchCallback - - @Inject - lateinit var viewModelFactory: ViewModelProvider.Factory + @Inject lateinit var blockingDialog: BlockingDialog + @Inject lateinit var disposables: CompositeDisposable + @Inject lateinit var navigator: Navigator + @Inject lateinit var conversationsAdapter: ConversationsAdapter + @Inject lateinit var drawerBadgesExperiment: DrawerBadgesExperiment + @Inject lateinit var searchAdapter: SearchAdapter + @Inject lateinit var itemTouchCallback: ConversationItemTouchCallback + @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 { @@ -116,10 +101,14 @@ class MainActivity : QkThemedActivity(), MainView { scheduled.clicks().map { NavItem.SCHEDULED }, blocking.clicks().map { NavItem.BLOCKING }, settings.clicks().map { NavItem.SETTINGS }, + help.clicks().map { NavItem.HELP }, invite.clicks().map { NavItem.INVITE })) } override val optionsItemIntent: Subject = PublishSubject.create() + override val plusBannerIntent by lazy { plusBanner.clicks() } + override val dismissRatingIntent by lazy { rateDismiss.clicks() } + override val rateIntent by lazy { rateOkay.clicks() } override val conversationsSelectedIntent by lazy { conversationsAdapter.selectionChanges } override val confirmDeleteIntent: Subject> = PublishSubject.create() override val swipeConversationIntent by lazy { itemTouchCallback.swipes } @@ -144,51 +133,63 @@ 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 { _, _ -> - syncingProgress?.progressTintList = ColorStateList.valueOf(getColor(R.color.tools_theme)) - syncingProgress?.indeterminateTintList = ColorStateList.valueOf(getColor(R.color.tools_theme)) + syncingProgress?.progressTintList = ColorStateList.valueOf(theme.blockingFirst().theme) + syncingProgress?.indeterminateTintList = ColorStateList.valueOf(theme.blockingFirst().theme) } toggle.syncState() - toolbar.setNavigationIcon(lineageos.platform.R.drawable.ic_hamburger) - toolbar.navigationIcon?.setTint(resources.getColor(R.color.tools_theme)) toolbar.setNavigationOnClickListener { dismissKeyboard() 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 - - // Set the color for the drawer icons - val states = arrayOf(intArrayOf(android.R.attr.state_activated), - intArrayOf(-android.R.attr.state_activated)) - resolveThemeColor(android.R.attr.textColorPrimary) - .let { textSecondary -> ColorStateList(states, intArrayOf(getColor(R.color.tools_theme), textSecondary)) } - .let { tintList -> - inboxIcon.imageTintList = tintList - inboxLabel.setTextColor(tintList) - archivedIcon.imageTintList = tintList - archivedLabel.setTextColor(tintList) + theme + .autoDisposable(scope()) + .subscribe { theme -> + // Set the color for the drawer icons + 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 -> + inboxIcon.imageTintList = tintList + archivedIcon.imageTintList = tintList + } + + // Miscellaneous views + listOf(plusBadge1, plusBadge2).forEach { badge -> + badge.setBackgroundTint(theme.theme) + badge.setTextColor(theme.textPrimary) + } + syncingProgress?.progressTintList = ColorStateList.valueOf(theme.theme) + syncingProgress?.indeterminateTintList = ColorStateList.valueOf(theme.theme) + plusIcon.setTint(theme.theme) + rateIcon.setTint(theme.theme) + compose.setBackgroundTint(theme.theme) + + // Set the FAB compose icon color + compose.setTint(theme.textPrimary) } - // Miscellaneous views - syncingProgress?.progressTintList = ColorStateList.valueOf(getColor(R.color.tools_theme)) - syncingProgress?.indeterminateTintList = ColorStateList.valueOf(getColor(R.color.tools_theme)) - - // Set the FAB compose icon color - compose.setTint(getColor(R.color.tools_theme)) - - 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?) { @@ -239,8 +240,16 @@ class MainActivity : QkThemedActivity(), MainView { toolbar.menu.findItem(R.id.unread)?.isVisible = !markRead && selectedConversations != 0 toolbar.menu.findItem(R.id.block)?.isVisible = selectedConversations != 0 + listOf(plusBadge1, plusBadge2).forEach { badge -> + badge.isVisible = drawerBadgesExperiment.variant && !state.upgraded + } + + plusBanner.isVisible = !state.upgraded + rateLayout.setVisible(state.showRating) + compose.setVisible(state.page is Inbox || state.page is Archived) conversationsAdapter.emptyView = empty.takeIf { state.page is Inbox || state.page is Archived } + searchAdapter.emptyView = empty.takeIf { state.page is Searching } when (state.page) { is Inbox -> { @@ -276,10 +285,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 -> { @@ -319,7 +329,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() { @@ -355,13 +370,17 @@ class MainActivity : QkThemedActivity(), MainView { conversationsAdapter.clearSelection() } + override fun themeChanged() { + recyclerView.scrapViews() + } + override fun showBlockingDialog(conversations: List, block: Boolean) { blockingDialog.show(this, conversations, block) } override fun showDeleteDialog(conversations: List) { val count = conversations.size - AlertDialog.Builder(this, R.style.customAlertDialog) + AlertDialog.Builder(this) .setTitle(R.string.dialog_delete_title) .setMessage(resources.getQuantityString(R.plurals.dialog_delete_message, count, count)) .setPositiveButton(R.string.button_delete) { _, _ -> confirmDeleteIntent.onNext(conversations) } @@ -369,14 +388,14 @@ class MainActivity : QkThemedActivity(), MainView { .show() } - override fun showChangelog(changelog: ChangelogManager.Changelog) { + override fun showChangelog(changelog: ChangelogManager.CumulativeChangelog) { changelogDialog.show(changelog) } override fun showArchivedSnackbar() { Snackbar.make(drawerLayout, R.string.toast_archived, Snackbar.LENGTH_LONG).apply { setAction(R.string.button_undo) { undoArchiveIntent.onNext(Unit) } - setActionTextColor(getColor(R.color.tools_theme)) + setActionTextColor(colors.theme().theme) show() } } 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..9da85458eb3aabb1260a1b05df7d9db193b3447b 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,8 @@ data class MainState( val page: MainPage = Inbox(), val drawerOpen: Boolean = false, val showRating: Boolean = false, - val syncing: SyncRepository.SyncProgress = SyncRepository.SyncProgress.Idle(), + val upgraded: Boolean = false, + 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..03cc29d597a2e92a61884f02e5495a5cf93e8e83 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,13 +26,16 @@ 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 val homeIntent: Observable<*> val navigationIntent: Observable val optionsItemIntent: Observable + val plusBannerIntent: Observable<*> + val dismissRatingIntent: Observable<*> + val rateIntent: Observable<*> val conversationsSelectedIntent: Observable> val confirmDeleteIntent: Observable> val swipeConversationIntent: Observable> @@ -44,11 +47,12 @@ 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) + fun showChangelog(changelog: ChangelogManager.CumulativeChangelog) fun showArchivedSnackbar() } -enum class NavItem { BACK, INBOX, ARCHIVED, BACKUP, SCHEDULED, BLOCKING, SETTINGS, PLUS, HELP, INVITE } +enum class NavItem { BACK, INBOX, ARCHIVED, BACKUP, SCHEDULED, BLOCKING, SETTINGS, HELP, INVITE } \ No newline at end of file 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..a24ad1e9212fd1112d3fa56d110fea7224ecd79c 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 @@ -18,17 +18,27 @@ */ package com.moez.QKSMS.feature.main -import android.net.Uri import androidx.recyclerview.widget.ItemTouchHelper 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.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.BillingManager import com.moez.QKSMS.manager.ChangelogManager import com.moez.QKSMS.manager.PermissionManager +import com.moez.QKSMS.manager.RatingManager import com.moez.QKSMS.model.SyncLog import com.moez.QKSMS.repository.ConversationRepository import com.moez.QKSMS.repository.SyncRepository @@ -40,22 +50,23 @@ import io.reactivex.rxkotlin.plusAssign import io.reactivex.rxkotlin.withLatestFrom import io.reactivex.schedulers.Schedulers import io.realm.Realm +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import java.util.* import java.util.concurrent.TimeUnit import javax.inject.Inject -import javax.inject.Named class MainViewModel @Inject constructor( - @Named("threadId") private val threadId: Long, + billingManager: BillingManager, + contactAddedListener: ContactAddedListener, 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, @@ -64,8 +75,9 @@ class MainViewModel @Inject constructor( private val navigator: Navigator, private val permissionManager: PermissionManager, private val prefs: Preferences, - private val syncMessages: SyncMessages, - private val syncContacts: ContactSync + private val ratingManager: RatingManager, + private val syncContacts: SyncContacts, + private val syncMessages: SyncMessages ) : QkViewModel(MainState(page = Inbox(data = conversationRepo.getConversations()))) { init { @@ -74,6 +86,7 @@ class MainViewModel @Inject constructor( disposables += markArchived disposables += markUnarchived disposables += migratePreferences + disposables += syncContacts disposables += syncMessages // Show the syncing UI @@ -82,19 +95,35 @@ class MainViewModel @Inject constructor( .distinctUntilChanged() .subscribe { syncing -> newState { copy(syncing = syncing) } } + // Update the upgraded status + disposables += billingManager.upgradeStatus + .subscribe { upgraded -> newState { copy(upgraded = upgraded) } } + + // Show the rating UI + disposables += ratingManager.shouldShowRating + .subscribe { show -> newState { copy(showRating = show) } } + + // Migrate the preferences from 2.7.3 migratePreferences.execute(Unit) // If we have all permissions and we've never run a sync, run a sync. This will be the case // when upgrading from 2.7.3, or if the app's data was cleared - val lastSync = Realm.getDefaultInstance().use { realm -> - realm.where(SyncLog::class.java)?.max("date") ?: 0 - } + val lastSync = Realm.getDefaultInstance().use { realm -> realm.where(SyncLog::class.java)?.max("date") ?: 0 } if (lastSync == 0 && permissionManager.isDefaultSms() && permissionManager.hasReadSms() && permissionManager.hasContacts()) { 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) } + } + + ratingManager.addSession() markAllSeen.execute(Unit) } @@ -107,6 +136,7 @@ class MainViewModel @Inject constructor( } val permissions = view.activityResumedIntent + .filter { resumed -> resumed } .observeOn(Schedulers.io()) .map { Triple(permissionManager.isDefaultSms(), permissionManager.hasReadSms(), permissionManager.hasContacts()) } .distinctUntilChanged() @@ -133,6 +163,7 @@ class MainViewModel @Inject constructor( .autoDisposable(view.scope()) .subscribe { intent -> when (intent.getStringExtra("screen")) { + "compose" -> navigator.showConversation(intent.getLongExtra("threadId", 0)) "blocking" -> navigator.showBlockedConversations() } } @@ -140,12 +171,11 @@ class MainViewModel @Inject constructor( // Show changelog if (changelogManager.didUpdate()) { if (Locale.getDefault().language.startsWith("en")) { - disposables += changelogManager.getChangelog() - .timeout(3, TimeUnit.SECONDS) // If it takes long than 3s, we'll just try again next time - .subscribe({ changelog -> - changelogManager.markChangelogSeen() - view.showChangelog(changelog) - }, {}) // Ignore error + GlobalScope.launch(Dispatchers.Main) { + val changelog = changelogManager.getChangelog() + changelogManager.markChangelogSeen() + view.showChangelog(changelog) + } } else { changelogManager.markChangelogSeen() } @@ -155,7 +185,7 @@ class MainViewModel @Inject constructor( view.changelogMoreIntent .autoDisposable(view.scope()) - .subscribe { navigator.openUri(Uri.parse("https://gitlab.e.foundation/e/apps/Message/-/releases")) } + .subscribe { navigator.showChangelog() } view.queryChangedIntent .debounce(200, TimeUnit.MILLISECONDS) @@ -167,6 +197,8 @@ class MainViewModel @Inject constructor( query } .filter { query -> query.length >= 2 } + .map { query -> query.trim() } + .distinctUntilChanged() .doOnNext { newState { val page = (page as? Searching) ?: Searching() @@ -178,6 +210,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() } @@ -217,7 +263,7 @@ class MainViewModel @Inject constructor( NavItem.SCHEDULED -> navigator.showScheduled() NavItem.BLOCKING -> navigator.showBlockedConversations() NavItem.SETTINGS -> navigator.showSettings() - NavItem.PLUS -> navigator.showQksmsPlusActivity("main_menu") + NavItem.HELP -> navigator.showSupport() NavItem.INVITE -> navigator.showInvite() else -> Unit @@ -272,7 +318,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() @@ -323,6 +368,24 @@ class MainViewModel @Inject constructor( .autoDisposable(view.scope()) .subscribe() + view.plusBannerIntent + .autoDisposable(view.scope()) + .subscribe { + newState { copy(drawerOpen = false) } + navigator.showQksmsPlusActivity("main_banner") + } + + view.rateIntent + .autoDisposable(view.scope()) + .subscribe { + navigator.showRating() + ratingManager.rate() + } + + view.dismissRatingIntent + .autoDisposable(view.scope()) + .subscribe { ratingManager.dismiss() } + view.conversationsSelectedIntent .withLatestFrom(state) { selection, state -> val conversations = selection.mapNotNull(conversationRepo::getConversation) @@ -365,6 +428,7 @@ class MainViewModel @Inject constructor( when (action) { Preferences.SWIPE_ACTION_ARCHIVE -> markArchived.execute(listOf(threadId)) { view.showArchivedSnackbar() } Preferences.SWIPE_ACTION_DELETE -> view.showDeleteDialog(listOf(threadId)) + Preferences.SWIPE_ACTION_BLOCK -> view.showBlockingDialog(listOf(threadId), true) Preferences.SWIPE_ACTION_CALL -> conversationRepo.getConversation(threadId)?.recipients?.firstOrNull()?.address?.let(navigator::makePhoneCall) Preferences.SWIPE_ACTION_READ -> markRead.execute(listOf(threadId)) Preferences.SWIPE_ACTION_UNREAD -> markUnread.execute(listOf(threadId)) @@ -386,12 +450,6 @@ class MainViewModel @Inject constructor( } .autoDisposable(view.scope()) .subscribe() - - - - if (threadId == 0L) { - syncContacts.execute(Unit) - } } -} +} \ No newline at end of file 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 dacaac38f07d7f6ade173ae3e2c8071936575644..4189d9cc874ca84944b3685e3839c652d5808bd8 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 @@ -18,13 +18,16 @@ */ package com.moez.QKSMS.feature.main +import android.annotation.SuppressLint import android.content.Context +import android.os.Build import android.text.SpannableString import android.text.Spanned import android.text.style.BackgroundColorSpan import android.view.LayoutInflater import android.view.ViewGroup -import com.google.android.material.color.MaterialColors.getColor +import androidx.annotation.RequiresApi + import com.moez.QKSMS.R import com.moez.QKSMS.common.Navigator import com.moez.QKSMS.common.base.QkAdapter @@ -34,6 +37,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 +60,13 @@ class SearchAdapter @Inject constructor( } } - override fun onBindViewHolder(viewHolder: QkViewHolder, position: Int) { + @SuppressLint("StringFormatInvalid") + @RequiresApi(Build.VERSION_CODES.M) + 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 +76,23 @@ class SearchAdapter @Inject constructor( title.setSpan(BackgroundColorSpan(context.getColor(R.color.tools_theme)), 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/plus/PlusActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/plus/PlusActivity.kt index 806856ac2f85d5cb893a19119dec4261d4c37921..1a8a31187c8a75c2114903574cb6e5b2fa1e2f01 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/plus/PlusActivity.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/plus/PlusActivity.kt @@ -24,23 +24,26 @@ import androidx.core.view.children import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProviders import com.jakewharton.rxbinding2.view.clicks -import com.jakewharton.rxbinding2.view.enabled import com.moez.QKSMS.BuildConfig import com.moez.QKSMS.R import com.moez.QKSMS.common.base.QkThemedActivity -import com.moez.QKSMS.common.util.BillingManager import com.moez.QKSMS.common.util.FontProvider +import com.moez.QKSMS.common.util.extensions.makeToast import com.moez.QKSMS.common.util.extensions.resolveThemeColor 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.widget.PreferenceView import com.moez.QKSMS.feature.plus.experiment.UpgradeButtonExperiment +import com.moez.QKSMS.manager.BillingManager import dagger.android.AndroidInjection -import io.reactivex.Observable import kotlinx.android.synthetic.main.collapsing_toolbar.* import kotlinx.android.synthetic.main.preference_view.view.* import kotlinx.android.synthetic.main.qksms_plus_activity.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject class PlusActivity : QkThemedActivity(), PlusView { @@ -112,7 +115,14 @@ class PlusActivity : QkThemedActivity(), PlusView { } override fun initiatePurchaseFlow(billingManager: BillingManager, sku: String) { - billingManager.initiatePurchaseFlow(this, sku) + GlobalScope.launch(Dispatchers.Main) { + try { + billingManager.initiatePurchaseFlow(this@PlusActivity, sku) + } catch (e: Exception) { + Timber.w(e) + makeToast(R.string.qksms_plus_error) + } + } } } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/plus/PlusView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/plus/PlusView.kt index 8d7c67e84bc470bb3148e6e80b7fc11974821402..261f9a4c9a1ee522378f50fcf50b025ebb178430 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/plus/PlusView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/plus/PlusView.kt @@ -19,7 +19,7 @@ package com.moez.QKSMS.feature.plus import com.moez.QKSMS.common.base.QkView -import com.moez.QKSMS.common.util.BillingManager +import com.moez.QKSMS.manager.BillingManager import io.reactivex.Observable interface PlusView : QkView { diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/plus/PlusViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/plus/PlusViewModel.kt index 95876ae580f8251924459276b220e9e3846115df..661f77297d545d436f5fd1ed7a240d08b4d7d0d6 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/plus/PlusViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/plus/PlusViewModel.kt @@ -20,8 +20,8 @@ package com.moez.QKSMS.feature.plus import com.moez.QKSMS.common.Navigator import com.moez.QKSMS.common.base.QkViewModel -import com.moez.QKSMS.common.util.BillingManager import com.moez.QKSMS.manager.AnalyticsManager +import com.moez.QKSMS.manager.BillingManager import com.uber.autodispose.android.lifecycle.scope import com.uber.autodispose.autoDisposable import io.reactivex.Observable @@ -80,4 +80,4 @@ class PlusViewModel @Inject constructor( .subscribe { navigator.showSettings() } } -} \ No newline at end of file +} 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 8270c501e48f52dcbee775686c028a1b738e37ba..91d1e624596186a991cdeb9714548148e713d662 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/scheduled/ScheduledViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledViewModel.kt index 00883e82708c524788c1d2c77dbb4256a1680085..3cc99fae4b3f95985dd69d0650259fd1ff53c17b 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledViewModel.kt @@ -22,25 +22,22 @@ 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.BillingManager import com.moez.QKSMS.common.util.ClipboardUtils import com.moez.QKSMS.common.util.extensions.makeToast import com.moez.QKSMS.interactor.SendScheduledMessage -import com.moez.QKSMS.repository.MessageRepository +import com.moez.QKSMS.manager.BillingManager import com.moez.QKSMS.repository.ScheduledMessageRepository import com.uber.autodispose.android.lifecycle.scope import com.uber.autodispose.autoDisposable -import io.reactivex.rxkotlin.plusAssign import io.reactivex.rxkotlin.withLatestFrom import javax.inject.Inject class ScheduledViewModel @Inject constructor( - billingManager: BillingManager, - private val context: Context, - private val messageRepo: MessageRepository, - private val navigator: Navigator, - private val scheduledMessageRepo: ScheduledMessageRepository, - private val sendScheduledMessage: SendScheduledMessage + billingManager: BillingManager, + private val context: Context, + private val navigator: Navigator, + private val scheduledMessageRepo: ScheduledMessageRepository, + private val sendScheduledMessage: SendScheduledMessage ) : QkViewModel(ScheduledState( scheduledMessages = scheduledMessageRepo.getScheduledMessages() )) { 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 7c4d41d7cbbaafe181cb178caf5d83d109e0a459..f09adc3e957db38a76605d04960554fb027d859c 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 @@ -24,6 +24,7 @@ import android.content.Context import android.os.Build import android.text.format.DateFormat import android.view.View +import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible import com.bluelinelabs.conductor.RouterTransaction import com.google.android.material.snackbar.Snackbar @@ -39,10 +40,13 @@ import com.moez.QKSMS.common.util.Colors import com.moez.QKSMS.common.util.extensions.animateLayoutChanges import com.moez.QKSMS.common.util.extensions.setBackgroundTint import com.moez.QKSMS.common.util.extensions.setVisible -import com.moez.QKSMS.common.widget.FieldDialog import com.moez.QKSMS.common.widget.PreferenceView +import com.moez.QKSMS.common.widget.QkSwitch +import com.moez.QKSMS.common.widget.TextInputDialog import com.moez.QKSMS.feature.settings.about.AboutController +import com.moez.QKSMS.feature.settings.autodelete.AutoDeleteDialog import com.moez.QKSMS.feature.settings.swipe.SwipeActionsController +import com.moez.QKSMS.feature.themepicker.ThemePickerController import com.moez.QKSMS.injection.appComponent import com.moez.QKSMS.repository.SyncRepository import com.moez.QKSMS.util.Preferences @@ -51,10 +55,15 @@ import com.uber.autodispose.autoDisposable import io.reactivex.Observable import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import kotlinx.android.synthetic.main.settings_controller.* import kotlinx.android.synthetic.main.settings_controller.view.* import kotlinx.android.synthetic.main.settings_switch_widget.view.* +import kotlinx.android.synthetic.main.settings_theme_widget.* import javax.inject.Inject +import kotlin.coroutines.resume class SettingsController : QkController(), SettingsView { @@ -67,14 +76,18 @@ class SettingsController : QkController = PublishSubject.create() private val startTimeSelectedSubject: Subject> = PublishSubject.create() private val endTimeSelectedSubject: Subject> = PublishSubject.create() private val signatureSubject: Subject = PublishSubject.create() + private val autoDeleteSubject: Subject = PublishSubject.create() private val progressAnimator by lazy { ObjectAnimator.ofInt(syncingProgress, "progress", 0, 0) } @@ -83,6 +96,9 @@ class SettingsController : QkController = sendDelayDialog.adapter.menuItemClicks - override fun signatureSet(): Observable = signatureSubject + override fun signatureChanged(): Observable = signatureSubject + + override fun autoDeleteChanged(): Observable = autoDeleteSubject override fun mmsSizeSelected(): Observable = mmsSizeDialog.adapter.menuItemClicks override fun render(state: SettingsState) { + themePreview.setBackgroundTint(state.theme) night.summary = state.nightModeSummary nightModeDialog.adapter.selectedItem = state.nightModeId + nightStart.setVisible(state.nightModeId == Preferences.NIGHT_MODE_AUTO) + nightStart.summary = state.nightStart + nightEnd.setVisible(state.nightModeId == Preferences.NIGHT_MODE_AUTO) + nightEnd.summary = state.nightEnd + + black.setVisible(state.nightModeId != Preferences.NIGHT_MODE_OFF) + black.checkbox.isChecked = state.black autoEmoji.checkbox.isChecked = state.autoEmojiEnabled @@ -147,11 +174,22 @@ class SettingsController : QkController context.getString(R.string.settings_auto_delete_never) + else -> context.resources.getQuantityString( + R.plurals.settings_auto_delete_summary, state.autoDelete, state.autoDelete) + } + + longAsMms.checkbox.isChecked = state.longAsMms + mmsSize.summary = state.maxMmsSizeSummary mmsSizeDialog.adapter.selectedItem = state.maxMmsSizeId @@ -171,10 +209,9 @@ class SettingsController : QkController + TimePickerDialog(activity, { _, newHour, newMinute -> startTimeSelectedSubject.onNext(Pair(newHour, newMinute)) }, hour, minute, DateFormat.is24HourFormat(activity)).show() } override fun showEndTimePicker(hour: Int, minute: Int) { - TimePickerDialog(activity, R.style.customAlertDialog, TimePickerDialog.OnTimeSetListener { _, newHour, newMinute -> + TimePickerDialog(activity, { _, newHour, newMinute -> endTimeSelectedSubject.onNext(Pair(newHour, newMinute)) }, hour, minute, DateFormat.is24HourFormat(activity)).show() } @@ -199,6 +236,20 @@ class SettingsController : QkController { cont -> + AlertDialog.Builder(activity!!) + .setTitle(R.string.settings_auto_delete_warning) + .setMessage(context.resources.getString(R.string.settings_auto_delete_warning_message, messages)) + .setOnCancelListener { cont.resume(false) } + .setNegativeButton(R.string.button_cancel) { _, _ -> cont.resume(false) } + .setPositiveButton(R.string.button_yes) { _, _ -> cont.resume(true) } + .show() + } + } + override fun showMmsSizePicker() = mmsSizeDialog.show(activity!!) override fun showSwipeActions() { @@ -207,6 +258,12 @@ class SettingsController : QkController newState { copy(theme = theme.theme) } } val nightModeLabels = context.resources.getStringArray(R.array.night_modes) disposables += prefs.nightMode.asObservable() .subscribe { nightMode -> - newState { - copy(nightModeSummary = nightModeLabels[nightMode], nightModeId = nightMode) - } + newState { copy(nightModeSummary = nightModeLabels[nightMode], nightModeId = nightMode) } } disposables += prefs.nightStart.asObservable() @@ -85,11 +95,7 @@ class SettingsPresenter @Inject constructor( val delayedSendingLabels = context.resources.getStringArray(R.array.delayed_sending_labels) disposables += prefs.sendDelay.asObservable() - .subscribe { id -> - newState { - copy(sendDelaySummary = delayedSendingLabels[id], sendDelayId = id) - } - } + .subscribe { id -> newState { copy(sendDelaySummary = delayedSendingLabels[id], sendDelayId = id) } } disposables += prefs.delivery.asObservable() .subscribe { enabled -> newState { copy(deliveryEnabled = enabled) } } @@ -100,11 +106,12 @@ class SettingsPresenter @Inject constructor( val textSizeLabels = context.resources.getStringArray(R.array.text_sizes) disposables += prefs.textSize.asObservable() .subscribe { textSize -> - newState { - copy(textSizeSummary = textSizeLabels[textSize], textSizeId = textSize) - } + newState { copy(textSizeSummary = textSizeLabels[textSize], textSizeId = textSize) } } + disposables += prefs.autoColor.asObservable() + .subscribe { autoColor -> newState { copy(autoColor = autoColor) } } + disposables += prefs.systemFont.asObservable() .subscribe { enabled -> newState { copy(systemFontEnabled = enabled) } } @@ -114,14 +121,18 @@ class SettingsPresenter @Inject constructor( disposables += prefs.mobileOnly.asObservable() .subscribe { enabled -> newState { copy(mobileOnly = enabled) } } + disposables += prefs.autoDelete.asObservable() + .subscribe { autoDelete -> newState { copy(autoDelete = autoDelete) } } + + 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() .subscribe { maxMmsSize -> val index = mmsSizeIds.indexOf(maxMmsSize) - newState { - copy(maxMmsSizeSummary = mmsSizeLabels[index], maxMmsSizeId = maxMmsSize) - } + newState { copy(maxMmsSizeSummary = mmsSizeLabels[index], maxMmsSizeId = maxMmsSize) } } disposables += syncRepo.syncProgress @@ -141,6 +152,21 @@ class SettingsPresenter @Inject constructor( Timber.v("Preference click: ${context.resources.getResourceName(it.id)}") when (it.id) { + R.id.theme -> view.showThemePicker() + + R.id.night -> view.showNightModeDialog() + + R.id.nightStart -> { + val date = nightModeManager.parseTime(prefs.nightStart.get()) + view.showStartTimePicker(date.get(Calendar.HOUR_OF_DAY), date.get(Calendar.MINUTE)) + } + + R.id.nightEnd -> { + val date = nightModeManager.parseTime(prefs.nightEnd.get()) + view.showEndTimePicker(date.get(Calendar.HOUR_OF_DAY), date.get(Calendar.MINUTE)) + } + + R.id.black -> prefs.black.set(!prefs.black.get()) R.id.autoEmoji -> prefs.autoEmoji.set(!prefs.autoEmoji.get()) @@ -156,12 +182,21 @@ 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.autoDelete -> view.showAutoDeleteDialog(prefs.autoDelete.get()) + + R.id.longAsMms -> prefs.longAsMms.set(!prefs.longAsMms.get()) + R.id.mmsSize -> view.showMmsSizePicker() R.id.sync -> syncMessages.execute(Unit) @@ -181,19 +216,76 @@ class SettingsPresenter @Inject constructor( }) } + view.nightModeSelected() + .withLatestFrom(billingManager.upgradeStatus) { mode, upgraded -> + if (!upgraded && mode == Preferences.NIGHT_MODE_AUTO) { + view.showQksmsPlusSnackbar() + } else { + nightModeManager.updateNightMode(mode) + } + } + .autoDisposable(view.scope()) + .subscribe() + + view.viewQksmsPlusClicks() + .autoDisposable(view.scope()) + .subscribe { navigator.showQksmsPlusActivity("settings_night") } + + view.nightStartSelected() + .autoDisposable(view.scope()) + .subscribe { nightModeManager.setNightStart(it.first, it.second) } + + view.nightEndSelected() + .autoDisposable(view.scope()) + .subscribe { nightModeManager.setNightEnd(it.first, it.second) } + view.textSizeSelected() .autoDisposable(view.scope()) .subscribe(prefs.textSize::set) view.sendDelaySelected() + .withLatestFrom(billingManager.upgradeStatus) { duration, upgraded -> + if (!upgraded && duration != 0) { + view.showQksmsPlusSnackbar() + } else { + prefs.sendDelay.set(duration) + } + } .autoDisposable(view.scope()) .subscribe() - view.signatureSet() + view.signatureChanged() .doOnNext(prefs.signature::set) .autoDisposable(view.scope()) .subscribe() + view.autoDeleteChanged() + .observeOn(Schedulers.io()) + .filter { maxAge -> + if (maxAge == 0) { + return@filter true + } + + val counts = messageRepo.getOldMessageCounts(maxAge) + if (counts.values.sum() == 0) { + return@filter true + } + + runBlocking { view.showAutoDeleteWarningDialog(counts.values.sum()) } + } + .doOnNext { maxAge -> + when (maxAge == 0) { + true -> AutoDeleteService.cancelJob(context) + false -> { + AutoDeleteService.scheduleJob(context) + deleteOldMessages.execute(Unit) + } + } + } + .doOnNext(prefs.autoDelete::set) + .autoDisposable(view.scope()) + .subscribe() + view.mmsSizeSelected() .autoDisposable(view.scope()) .subscribe(prefs.mmsSize::set) 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..35cf2252aac02b5a56aebc3a5a5b3af40ee0c2a7 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,9 @@ data class SettingsState( val splitSmsEnabled: Boolean = false, val stripUnicodeEnabled: Boolean = false, val mobileOnly: Boolean = false, + val autoDelete: Int = 0, + 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/settings/SettingsView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/settings/SettingsView.kt index d94399c468980e83efd351316d15fa83d38230f4..e818b034d33d373646fb19aa68321c7b781e079a 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/settings/SettingsView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/settings/SettingsView.kt @@ -31,7 +31,8 @@ interface SettingsView : QkViewContract { fun nightEndSelected(): Observable> fun textSizeSelected(): Observable fun sendDelaySelected(): Observable - fun signatureSet(): Observable + fun signatureChanged(): Observable + fun autoDeleteChanged(): Observable fun mmsSizeSelected(): Observable fun showQksmsPlusSnackbar() fun showNightModeDialog() @@ -40,7 +41,10 @@ interface SettingsView : QkViewContract { fun showTextSizePicker() fun showDelayDurationDialog() fun showSignatureDialog(signature: String) + fun showAutoDeleteDialog(days: Int) + suspend fun showAutoDeleteWarningDialog(messages: Int): Boolean fun showMmsSizePicker() fun showSwipeActions() + fun showThemePicker() fun showAbout() } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/settings/about/AboutController.kt b/presentation/src/main/java/com/moez/QKSMS/feature/settings/about/AboutController.kt index 4fb15065270ac9960c56d5ad8d7e556150e5fa17..7385a29c91db11d1b3997a925a8419ca859dc21c 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/settings/about/AboutController.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/settings/about/AboutController.kt @@ -31,8 +31,7 @@ import javax.inject.Inject class AboutController : QkController(), AboutView { - @Inject - override lateinit var presenter: AboutPresenter + @Inject override lateinit var presenter: AboutPresenter init { appComponent.inject(this) @@ -40,7 +39,7 @@ class AboutController : QkController(), AboutVi } override fun onViewCreated() { - app_version.summary = BuildConfig.VERSION_NAME + version.summary = BuildConfig.VERSION_NAME } override fun onAttach(view: View) { diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/settings/about/AboutPresenter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/settings/about/AboutPresenter.kt index dd6c57511eaa087f28b51886d06269170140bdd3..a3373a7cfbb19f0ba780cb72ea3ef80dbe041306 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/settings/about/AboutPresenter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/settings/about/AboutPresenter.kt @@ -18,7 +18,6 @@ */ package com.moez.QKSMS.feature.settings.about -import android.net.Uri import com.moez.QKSMS.R import com.moez.QKSMS.common.Navigator import com.moez.QKSMS.common.base.QkPresenter @@ -37,14 +36,15 @@ class AboutPresenter @Inject constructor( .autoDisposable(view.scope()) .subscribe { preference -> when (preference.id) { - R.id.fork -> navigator.openUri(Uri.parse("https://github.com/moezbhatti/qksms")) - R.id.source -> navigator.openUri(Uri.parse("https://gitlab.e.foundation/e/apps/message")) - R.id.copyright -> navigator.openUri(Uri.parse("https://gitlab.e.foundation/e/apps/Message/-/blob/master/AUTHORS")) - R.id.license -> navigator.openUri(Uri.parse("https://gitlab.e.foundation/e/apps/Message/-/blob/master/LICENSE")) - R.id.author -> navigator.openUri(Uri.parse("https://gitlab.e.foundation/e/apps/Message/-/blob/master/AUTHORS")) - R.id.privacy -> navigator.openUri(Uri.parse("https://e.foundation/legal-notice-privacy/")) - R.id.service_terms -> navigator.openUri(Uri.parse("https://e.foundation/legal-notice-privacy/")) - R.id.app_version -> navigator.openUri(Uri.parse("https://gitlab.e.foundation/e/apps/Message/-/releases")) + R.id.developer -> navigator.showDeveloper() + + R.id.source -> navigator.showSourceCode() + + R.id.changelog -> navigator.showChangelog() + + R.id.contact -> navigator.showSupport() + + R.id.license -> navigator.showLicense() } } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/settings/autodelete/AutoDeleteDialog.kt b/presentation/src/main/java/com/moez/QKSMS/feature/settings/autodelete/AutoDeleteDialog.kt new file mode 100644 index 0000000000000000000000000000000000000000..8f55c1413d37825910a4e83b5404ddf9c7042931 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/settings/autodelete/AutoDeleteDialog.kt @@ -0,0 +1,51 @@ +/* + * 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.settings.autodelete + +import android.app.Activity +import android.content.DialogInterface +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import com.moez.QKSMS.R +import kotlinx.android.synthetic.main.settings_auto_delete_dialog.view.* + +class AutoDeleteDialog(context: Activity, listener: (Int) -> Unit) : AlertDialog(context) { + + private val layout = LayoutInflater.from(context).inflate(R.layout.settings_auto_delete_dialog, null) + + init { + setView(layout) + setTitle(R.string.settings_auto_delete) + setMessage(context.getString(R.string.settings_auto_delete_dialog_message)) + setButton(DialogInterface.BUTTON_NEUTRAL, context.getString(R.string.button_cancel)) { _, _ -> } + setButton(DialogInterface.BUTTON_NEGATIVE, context.getString(R.string.settings_auto_delete_never)) { _, _ -> listener(0) } + setButton(DialogInterface.BUTTON_POSITIVE, context.getString(R.string.button_save)) { _, _ -> + listener(layout.field.text.toString().toIntOrNull() ?: 0) + } + } + + fun setExpiry(days: Int): AutoDeleteDialog { + when (days) { + 0 -> layout.field.text = null + else -> layout.field.setText(days.toString()) + } + return this + } + +} diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsController.kt b/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsController.kt index ab2b6fcd9a9dff2842a1f3429236c79a3330ebd9..fe3c05a7b50b6d99976e376880d8532c891ad256 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsController.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsController.kt @@ -21,7 +21,7 @@ package com.moez.QKSMS.feature.settings.swipe import android.view.View import androidx.core.content.ContextCompat import androidx.core.view.isVisible -import com.google.android.material.color.MaterialColors.getColor + import com.jakewharton.rxbinding2.view.clicks import com.moez.QKSMS.R import com.moez.QKSMS.common.QkDialog diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsPresenter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsPresenter.kt index f895b5ae5c607694d824690783e06f2780ce378a..ca02aac6dcee130381abe7c08c18c4dcd7e1b41e 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsPresenter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsPresenter.kt @@ -70,8 +70,9 @@ class SwipeActionsPresenter @Inject constructor( @DrawableRes private fun iconForAction(action: Int) = when (action) { - Preferences.SWIPE_ACTION_ARCHIVE -> R.drawable.ic_archive_black_24dp + Preferences.SWIPE_ACTION_ARCHIVE -> R.drawable.ic_archive_white_24dp Preferences.SWIPE_ACTION_DELETE -> R.drawable.ic_delete_white_24dp + Preferences.SWIPE_ACTION_BLOCK -> R.drawable.ic_block_white_24dp Preferences.SWIPE_ACTION_CALL -> R.drawable.ic_call_white_24dp Preferences.SWIPE_ACTION_READ -> R.drawable.ic_check_white_24dp Preferences.SWIPE_ACTION_UNREAD -> R.drawable.ic_markunread_black_24dp diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsState.kt index 37e6c61c16fb3e7c6d707652f713f03a4ff0dc75..cc7d14bae38bb62736a1bbf1ccd170e58e23dbb3 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/settings/swipe/SwipeActionsState.kt @@ -22,9 +22,9 @@ import androidx.annotation.DrawableRes import com.moez.QKSMS.R data class SwipeActionsState( - @DrawableRes val rightIcon: Int = R.drawable.ic_archive_black_24dp, + @DrawableRes val rightIcon: Int = R.drawable.ic_archive_white_24dp, val rightLabel: String = "", - @DrawableRes val leftIcon: Int = R.drawable.ic_archive_black_24dp, + @DrawableRes val leftIcon: Int = R.drawable.ic_archive_white_24dp, val leftLabel: String = "" ) \ 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 new file mode 100644 index 0000000000000000000000000000000000000000..15522443354ed0227071e28bf998a1cfe2fc080c --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/HSVPickerView.kt @@ -0,0 +1,162 @@ +/* + * 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.themepicker + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import androidx.constraintlayout.widget.ConstraintLayout +import com.moez.QKSMS.R +import com.moez.QKSMS.common.util.extensions.setBackgroundTint +import com.moez.QKSMS.common.util.extensions.setTint +import com.moez.QKSMS.common.util.extensions.within +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) { + + val selectedColor: Subject = BehaviorSubject.create() + + private val hues = arrayOf(0xFFFF0000, 0xFFFFFF00, 0xFF00FF00, 0xFF00FFFF, 0xFF0000FF, 0xFFFF00FF, 0xFFFF0000) + .map { it.toInt() }.toIntArray() + + private var min: Float = 0f + private var max = 0f + + private var hue = 0f + set(value) { + field = value + updateHue() + } + + init { + View.inflate(context, R.layout.hsv_picker_view, this) + + var swatchX = 0f + var swatchY = 0f + + saturation.setOnTouchListener { _, event -> + setupBounds() + when (event.action) { + MotionEvent.ACTION_DOWN -> { + swatchX = event.x - event.rawX + swatchY = event.y - event.rawY + parent.requestDisallowInterceptTouchEvent(true) + } + + MotionEvent.ACTION_MOVE -> { + // Calculate the new x/y position + swatch.x = (event.rawX + swatchX + min).within(min, max) + swatch.y = (event.rawY + swatchY + min).within(min, max) + + updateSelectedColor() + } + + MotionEvent.ACTION_UP -> { + parent.requestDisallowInterceptTouchEvent(false) + } + + else -> return@setOnTouchListener false + } + true + } + + var hueThumbX = 0f + + hueGroup.setOnTouchListener { _, event -> + setupBounds() + when (event.action) { + MotionEvent.ACTION_DOWN -> { + hueThumbX = event.x - event.rawX + parent.requestDisallowInterceptTouchEvent(true) + } + + MotionEvent.ACTION_MOVE -> { + val x = (event.rawX + hueThumbX + min).within(min, max) + + hueThumb.x = x + hue = (hueThumb.x - min) / (max - min) * 360 + + updateSelectedColor() + } + + MotionEvent.ACTION_UP -> { + parent.requestDisallowInterceptTouchEvent(false) + } + + else -> return@setOnTouchListener false + } + true + } + + hueTrack.clipToOutline = true + hueTrack.setImageDrawable(GradientDrawable(GradientDrawable.Orientation.LEFT_RIGHT, hues)) + } + + private fun setupBounds() { + if (min == 0f || max == 0f) { + min = saturation.x - swatch.width / 2 + max = min + saturation.width + } + } + + private fun updateSelectedColor() { + setupBounds() + + val range = max - min + val hsv = floatArrayOf(hue, (swatch.x - min) / range, 1 - (swatch.y - min) / range) + val color = Color.HSVToColor(hsv) + + swatch.setTint(color) + selectedColor.onNext(color) + } + + fun setColor(color: Int) { + // Convert the rgb color to HSV + val hsv = FloatArray(3).apply { + Color.colorToHSV(color, this) + hue = this[0] + } + + // Set the position of the swatch + post { + setupBounds() + val range = max - min + + hueThumb.x = range * hsv[0] / 360 + min + swatch.x = range * hsv[1] + min + swatch.y = range * (1 - hsv[2]) + min + + updateSelectedColor() + } + } + + private fun updateHue() { + val hsv = floatArrayOf(hue, 1f, 1f) + val tint = Color.HSVToColor(hsv) + saturation.setBackgroundTint(tint) + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000000000000000000000000000000000000..26e88c15d8f6bf66f8f4f7590adb88be03dc6e7a --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemeAdapter.kt @@ -0,0 +1,113 @@ +/* + * 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.themepicker + +import android.content.Context +import android.content.res.Resources +import android.view.LayoutInflater +import android.view.ViewGroup +import com.google.android.flexbox.FlexDirection +import com.google.android.flexbox.FlexWrap +import com.google.android.flexbox.FlexboxLayout +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.dpToPx +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 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 + +class ThemeAdapter @Inject constructor( + private val context: Context, + private val colors: Colors +) : QkAdapter>() { + + val colorSelected: Subject = PublishSubject.create() + + var selectedColor: Int = -1 + set(value) { + val oldPosition = data.indexOfFirst { it.contains(field) } + val newPosition = data.indexOfFirst { it.contains(value) } + + field = value + iconTint = colors.textPrimaryOnThemeForColor(value) + + oldPosition.takeIf { it != -1 }?.let { position -> notifyItemChanged(position) } + newPosition.takeIf { it != -1 }?.let { position -> notifyItemChanged(position) } + } + + private var iconTint = 0 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.theme_palette_list_item, parent, false) + view.palette.flexWrap = FlexWrap.WRAP + view.palette.flexDirection = FlexDirection.ROW + + return QkViewHolder(view) + } + + override fun onBindViewHolder(holder: QkViewHolder, position: Int) { + val palette = getItem(position) + + val screenWidth = Resources.getSystem().displayMetrics.widthPixels + val minPadding = (16 * 6).dpToPx(context) + val size = if (screenWidth - minPadding > (56 * 5).dpToPx(context)) { + 56.dpToPx(context) + } else { + (screenWidth - minPadding) / 5 + } + val swatchPadding = (screenWidth - size * 5) / 12 + + 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, holder.palette, false).apply { + + // Send clicks to the selected subject + setOnClickListener { colorSelected.onNext(color) } + + // Apply the color to the view + theme.setBackgroundTint(color) + + // Control the check visibility and tint + check.setVisible(color == selectedColor) + check.setTint(iconTint) + + // Update the size so that the spacing is perfectly even + layoutParams = (layoutParams as FlexboxLayout.LayoutParams).apply { + height = size + width = size + isWrapBefore = index % 5 == 0 + setMargins(swatchPadding, swatchPadding, swatchPadding, swatchPadding) + } + } + } + .forEach { theme -> holder.palette.addView(theme) } + } + +} \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePagerAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePagerAdapter.kt new file mode 100644 index 0000000000000000000000000000000000000000..c7185c2114356260c22eb97099a6efe3820043c5 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePagerAdapter.kt @@ -0,0 +1,55 @@ +/* + * 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.themepicker + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.viewpager.widget.PagerAdapter +import com.moez.QKSMS.R +import javax.inject.Inject + +class ThemePagerAdapter @Inject constructor(private val context: Context) : PagerAdapter() { + + override fun instantiateItem(container: ViewGroup, position: Int): Any { + return when (position) { + 1 -> container.findViewById(R.id.hsvPicker) + else -> container.findViewById(R.id.materialColors) + } + } + + override fun getPageTitle(position: Int): CharSequence? { + return when (position) { + 1 -> context.getString(R.string.theme_plus) + else -> context.getString(R.string.theme_material) + } + } + + override fun isViewFromObject(view: View, `object`: Any): Boolean { + return view == `object` + } + + override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { + } + + override fun getCount(): Int { + return 2 + } + +} \ 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 new file mode 100644 index 0000000000000000000000000000000000000000..65b339304de2017f8d57c4fd932249a0e262b472 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerController.kt @@ -0,0 +1,126 @@ +/* + * 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.themepicker + +import android.animation.ObjectAnimator +import android.view.View +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.Snackbar +import com.jakewharton.rxbinding2.view.clicks +import com.moez.QKSMS.R +import com.moez.QKSMS.common.base.QkController +import com.moez.QKSMS.common.util.Colors +import com.moez.QKSMS.common.util.extensions.dpToPx +import com.moez.QKSMS.common.util.extensions.setBackgroundTint +import com.moez.QKSMS.common.util.extensions.setVisible +import com.moez.QKSMS.feature.themepicker.injection.ThemePickerModule +import com.moez.QKSMS.injection.appComponent +import io.reactivex.Observable +import io.reactivex.subjects.PublishSubject +import io.reactivex.subjects.Subject +import kotlinx.android.synthetic.main.theme_picker_controller.* +import kotlinx.android.synthetic.main.theme_picker_hsv.* +import javax.inject.Inject + +class ThemePickerController( + val recipientId: Long = 0L +) : QkController(), ThemePickerView { + + @Inject override lateinit var presenter: ThemePickerPresenter + + @Inject lateinit var colors: Colors + @Inject lateinit var themeAdapter: ThemeAdapter + @Inject lateinit var themePagerAdapter: ThemePagerAdapter + + private val viewQksmsPlusSubject: Subject = PublishSubject.create() + + init { + appComponent + .themePickerBuilder() + .themePickerModule(ThemePickerModule(this)) + .build() + .inject(this) + + layoutRes = R.layout.theme_picker_controller + } + + override fun onViewCreated() { + pager.offscreenPageLimit = 1 + pager.adapter = themePagerAdapter + tabs.pager = pager + + themeAdapter.data = colors.materialColors + + materialColors.layoutManager = LinearLayoutManager(activity) + materialColors.adapter = themeAdapter + } + + override fun onAttach(view: View) { + super.onAttach(view) + presenter.bindIntents(this) + setTitle(R.string.title_theme) + showBackButton(true) + + themedActivity?.supportActionBar?.let { toolbar -> + ObjectAnimator.ofFloat(toolbar, "elevation", toolbar.elevation, 0f).start() + } + } + + override fun onDetach(view: View) { + super.onDetach(view) + + themedActivity?.supportActionBar?.let { toolbar -> + ObjectAnimator.ofFloat(toolbar, "elevation", toolbar.elevation, 8.dpToPx(toolbar.themedContext).toFloat()).start() + } + } + + override fun showQksmsPlusSnackbar() { + Snackbar.make(contentView, R.string.toast_qksms_plus, Snackbar.LENGTH_LONG).run { + setAction(R.string.button_more) { viewQksmsPlusSubject.onNext(Unit) } + setActionTextColor(colors.theme().theme) + show() + } + } + + override fun themeSelected(): Observable = themeAdapter.colorSelected + + override fun hsvThemeSelected(): Observable = picker.selectedColor + + override fun clearHsvThemeClicks(): Observable<*> = clear.clicks() + + override fun applyHsvThemeClicks(): Observable<*> = apply.clicks() + + override fun viewQksmsPlusClicks(): Observable<*> = viewQksmsPlusSubject + + override fun render(state: ThemePickerState) { + tabs.setRecipientId(state.recipientId) + + hex.setText(Integer.toHexString(state.newColor).takeLast(6)) + + applyGroup.setVisible(state.applyThemeVisible) + apply.setBackgroundTint(state.newColor) + apply.setTextColor(state.newTextColor) + } + + override fun setCurrentTheme(color: Int) { + picker.setColor(color) + themeAdapter.selectedColor = color + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000000000000000000000000000000000000..0e8b2ddd13ce72294f2f36dbe5305efa49f4bfa2 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerPresenter.kt @@ -0,0 +1,104 @@ +/* + * 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.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.Colors +import com.moez.QKSMS.manager.BillingManager +import com.moez.QKSMS.manager.WidgetManager +import com.moez.QKSMS.util.Preferences +import com.uber.autodispose.android.lifecycle.scope +import com.uber.autodispose.autoDisposable +import io.reactivex.rxkotlin.Observables +import io.reactivex.rxkotlin.withLatestFrom +import javax.inject.Inject +import javax.inject.Named + +class ThemePickerPresenter @Inject constructor( + prefs: Preferences, + @Named("recipientId") private val recipientId: Long, + private val billingManager: BillingManager, + private val colors: Colors, + private val navigator: Navigator, + private val widgetManager: WidgetManager +) : QkPresenter(ThemePickerState(recipientId = recipientId)) { + + private val theme: Preference = prefs.theme(recipientId) + + override fun bindIntents(view: ThemePickerView) { + super.bindIntents(view) + + theme.asObservable() + .autoDisposable(view.scope()) + .subscribe { color -> view.setCurrentTheme(color) } + + // Update the theme when a material theme is clicked + view.themeSelected() + .autoDisposable(view.scope()) + .subscribe { color -> + theme.set(color) + if (recipientId == 0L) { + widgetManager.updateTheme() + } + } + + // Update the color of the apply button + view.hsvThemeSelected() + .doOnNext { color -> newState { copy(newColor = color) } } + .map { color -> colors.textPrimaryOnThemeForColor(color) } + .doOnNext { color -> newState { copy(newTextColor = color) } } + .autoDisposable(view.scope()) + .subscribe() + + // Toggle the visibility of the apply group + Observables.combineLatest(theme.asObservable(), view.hsvThemeSelected()) { old, new -> old != new } + .autoDisposable(view.scope()) + .subscribe { themeChanged -> newState { copy(applyThemeVisible = themeChanged) } } + + // Update the theme, when apply is clicked + view.applyHsvThemeClicks() + .withLatestFrom(view.hsvThemeSelected()) { _, color -> color } + .withLatestFrom(billingManager.upgradeStatus) { color, upgraded -> + if (!upgraded) { + view.showQksmsPlusSnackbar() + } else { + theme.set(color) + if (recipientId == 0L) { + widgetManager.updateTheme() + } + } + } + .autoDisposable(view.scope()) + .subscribe() + + // Show QKSMS+ activity + view.viewQksmsPlusClicks() + .autoDisposable(view.scope()) + .subscribe { navigator.showQksmsPlusActivity("settings_theme") } + + // Reset the theme + view.clearHsvThemeClicks() + .withLatestFrom(theme.asObservable()) { _, color -> color } + .autoDisposable(view.scope()) + .subscribe { color -> view.setCurrentTheme(color) } + } + +} \ No newline at end of file diff --git a/domain/src/main/java/com/moez/QKSMS/repository/ImageRepository.kt b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerState.kt similarity index 77% rename from domain/src/main/java/com/moez/QKSMS/repository/ImageRepository.kt rename to presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerState.kt index da02a1f8bdf2e4923f1d82d3e89109bcdc03770c..81c44dcf1154ddf8b8b53819f2548561122657e1 100644 --- a/domain/src/main/java/com/moez/QKSMS/repository/ImageRepository.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerState.kt @@ -16,13 +16,11 @@ * You should have received a copy of the GNU General Public License * along with QKSMS. If not, see . */ -package com.moez.QKSMS.repository +package com.moez.QKSMS.feature.themepicker -import android.graphics.Bitmap -import android.net.Uri - -interface ImageRepository { - - fun loadImage(uri: Uri, width: Int, height: Int): Bitmap? - -} +data class ThemePickerState( + val recipientId: Long = 0, + val applyThemeVisible: Boolean = false, + val newColor: Int = -1, + val newTextColor: Int = -1 +) \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerView.kt new file mode 100644 index 0000000000000000000000000000000000000000..a3005f01c9ad1f7cc25bd6807ecb522997ac444e --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/ThemePickerView.kt @@ -0,0 +1,35 @@ +/* + * 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.themepicker + +import com.moez.QKSMS.common.base.QkViewContract +import io.reactivex.Observable + +interface ThemePickerView : QkViewContract { + + fun themeSelected(): Observable + fun hsvThemeSelected(): Observable + fun clearHsvThemeClicks(): Observable<*> + fun applyHsvThemeClicks(): Observable<*> + fun viewQksmsPlusClicks(): Observable<*> + + fun setCurrentTheme(color: Int) + fun showQksmsPlusSnackbar() + +} \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/injection/ThemePickerComponent.kt b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/injection/ThemePickerComponent.kt new file mode 100644 index 0000000000000000000000000000000000000000..6cc6845532441ac903d7c76f0b9f7cfda85fc9b7 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/injection/ThemePickerComponent.kt @@ -0,0 +1,37 @@ +/* + * 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.themepicker.injection + +import com.moez.QKSMS.feature.themepicker.ThemePickerController +import com.moez.QKSMS.injection.scope.ControllerScope +import dagger.Subcomponent + +@ControllerScope +@Subcomponent(modules = [ThemePickerModule::class]) +interface ThemePickerComponent { + + fun inject(controller: ThemePickerController) + + @Subcomponent.Builder + interface Builder { + fun themePickerModule(module: ThemePickerModule): Builder + fun build(): ThemePickerComponent + } + +} \ No newline at end of file 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 new file mode 100644 index 0000000000000000000000000000000000000000..c5c9b8d15a18d83c07491fd667c69b7332703162 --- /dev/null +++ b/presentation/src/main/java/com/moez/QKSMS/feature/themepicker/injection/ThemePickerModule.kt @@ -0,0 +1,35 @@ +/* + * 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.themepicker.injection + +import com.moez.QKSMS.feature.themepicker.ThemePickerController +import com.moez.QKSMS.injection.scope.ControllerScope +import dagger.Module +import dagger.Provides +import javax.inject.Named + +@Module +class ThemePickerModule(private val controller: ThemePickerController) { + + @Provides + @ControllerScope + @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 d51e6350e0de621904ceb6a234263e7b652081b6..608b4162524d3e343fd3517986eb014c91d5d259 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 @@ -18,8 +18,9 @@ */ package com.moez.QKSMS.feature.widget -import android.app.PendingIntent +import android.annotation.SuppressLint import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.content.Context import android.content.Intent import android.text.SpannableStringBuilder @@ -27,11 +28,14 @@ import android.view.View import android.widget.RemoteViews import android.widget.RemoteViewsService import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import androidx.core.text.color import com.moez.QKSMS.R import com.moez.QKSMS.common.util.Colors import com.moez.QKSMS.common.util.DateFormatter import com.moez.QKSMS.common.util.extensions.dpToPx import com.moez.QKSMS.common.util.extensions.getColorCompat +import com.moez.QKSMS.feature.compose.ComposeActivity import com.moez.QKSMS.feature.main.MainActivity import com.moez.QKSMS.injection.appComponent import com.moez.QKSMS.model.Contact @@ -40,6 +44,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 { @@ -62,7 +67,7 @@ class WidgetAdapter(intent: Intent) : RemoteViewsService.RemoteViewsFactory { private val night get() = prefs.night.get() private val black get() = prefs.black.get() - + private val theme get() = colors.theme() private val background get() = context.getColorCompat(when { night && black -> R.color.black @@ -104,6 +109,7 @@ class WidgetAdapter(intent: Intent) : RemoteViewsService.RemoteViewsFactory { } } + @SuppressLint("StringFormatInvalid") private fun getConversationView(position: Int): RemoteViews { val conversation = conversations[position] @@ -111,9 +117,9 @@ class WidgetAdapter(intent: Intent) : RemoteViewsService.RemoteViewsFactory { // Avatar remoteViews.setViewVisibility(R.id.avatar, if (smallWidget) View.GONE else View.VISIBLE) - remoteViews.setInt(R.id.avatar, "setBackgroundColor", context.getColor(R.color.tools_theme)) - remoteViews.setTextColor(R.id.initial, context.getColor(R.color.tools_theme)) - remoteViews.setInt(R.id.icon, "setColorFilter", context.getColor(R.color.textPrimary)) + remoteViews.setInt(R.id.avatar, "setBackgroundColor", theme.theme) + remoteViews.setTextColor(R.id.initial, theme.textPrimary) + remoteViews.setInt(R.id.icon, "setColorFilter", theme.textPrimary) remoteViews.setInt(R.id.avatarMask, "setColorFilter", background) val contact = conversation.recipients.map { recipient -> @@ -130,33 +136,39 @@ 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)) + remoteViews.setTextViewText(R.id.name, boldText(buildSpannedString { + append(conversation.getTitle()) + if (conversation.draft.isNotEmpty()) { + color(theme.theme) { append(" " + context.getString(R.string.main_draft)) } + } + }, 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() -> 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) + val clickIntent = Intent() + .putExtra("screen", "compose") + .putExtra("threadId", conversation.id) remoteViews.setOnClickFillInIntent(R.id.conversation, clickIntent) return remoteViews @@ -164,15 +176,13 @@ class WidgetAdapter(intent: Intent) : RemoteViewsService.RemoteViewsFactory { private fun getOverflowView(): RemoteViews { val view = RemoteViews(context.packageName, R.layout.widget_loading) - val intent = Intent(context, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) view.setTextColor(R.id.loadingText, textSecondary) view.setTextViewText(R.id.loadingText, context.getString(R.string.widget_more)) - view.setOnClickPendingIntent(R.id.loading, pendingIntent) + view.setOnClickFillInIntent(R.id.loading, Intent()) return view } - private fun boldText(text: String?, shouldBold: Boolean): CharSequence? = when { + private fun boldText(text: CharSequence?, shouldBold: Boolean): CharSequence? = when { shouldBold -> SpannableStringBuilder() .bold { append(text) } else -> text 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 0b7deea78a05bff52e6974b082f835db527c0593..6864d0d704280b57691703ebbb1e4245e5c566e9 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 @@ -41,11 +41,8 @@ import javax.inject.Inject class WidgetProvider : AppWidgetProvider() { - @Inject - lateinit var colors: Colors - - @Inject - lateinit var prefs: Preferences + @Inject lateinit var colors: Colors + @Inject lateinit var prefs: Preferences override fun onReceive(context: Context, intent: Intent) { AndroidInjection.inject(this, context) @@ -152,7 +149,7 @@ class WidgetProvider : AppWidgetProvider() { remoteViews.setOnClickPendingIntent(R.id.compose, composePI) // Conversation intent - val startActivityIntent = Intent(context, ComposeActivity::class.java) + val startActivityIntent = Intent(context, MainActivity::class.java) val startActivityPendingIntent = PendingIntent.getActivity(context, 0, startActivityIntent, PendingIntent.FLAG_UPDATE_CURRENT) remoteViews.setPendingIntentTemplate(R.id.conversations, startActivityPendingIntent) 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 dde4fde0ef710a71a66b01c00a889dd649a6edd0..eb90df0071295110b20d43ea86b6788f733902bc 100644 --- a/presentation/src/main/java/com/moez/QKSMS/injection/AppComponent.kt +++ b/presentation/src/main/java/com/moez/QKSMS/injection/AppComponent.kt @@ -33,16 +33,16 @@ 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.settings.SettingsController import com.moez.QKSMS.feature.settings.about.AboutController import com.moez.QKSMS.feature.settings.swipe.SwipeActionsController +import com.moez.QKSMS.feature.themepicker.injection.ThemePickerComponent 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 @@ -57,6 +57,7 @@ import javax.inject.Singleton interface AppComponent { fun conversationInfoBuilder(): ConversationInfoComponent.Builder + fun themePickerBuilder(): ThemePickerComponent.Builder fun inject(application: QKApplication) @@ -71,8 +72,6 @@ interface AppComponent { fun inject(dialog: QkDialog) - fun inject(fetcher: ContactImageLoader.ContactImageFetcher) - fun inject(service: WidgetAdapter) /** @@ -80,7 +79,6 @@ interface AppComponent { */ fun inject(service: QkChooserTargetService) - fun inject(view: AvatarView) fun inject(view: DetailedChipView) fun inject(view: PagerTitleView) @@ -90,4 +88,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 f6ea3f01afb47715bb812748cf95ec83a3a05fbb..cafd2d9974e1c930863c4bff2dc9a2b54ba8df90 100644 --- a/presentation/src/main/java/com/moez/QKSMS/injection/AppModule.kt +++ b/presentation/src/main/java/com/moez/QKSMS/injection/AppModule.kt @@ -21,34 +21,26 @@ 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 import com.moez.QKSMS.blocking.BlockingClient import com.moez.QKSMS.blocking.BlockingManager import com.moez.QKSMS.common.ViewModelFactory +import com.moez.QKSMS.common.util.BillingManagerImpl import com.moez.QKSMS.common.util.NotificationManagerImpl import com.moez.QKSMS.common.util.ShortcutManagerImpl import com.moez.QKSMS.feature.conversationinfo.injection.ConversationInfoComponent +import com.moez.QKSMS.feature.themepicker.injection.ThemePickerComponent import com.moez.QKSMS.listener.ContactAddedListener import com.moez.QKSMS.listener.ContactAddedListenerImpl -import com.moez.QKSMS.manager.ActiveConversationManager -import com.moez.QKSMS.manager.ActiveConversationManagerImpl -import com.moez.QKSMS.manager.AlarmManager -import com.moez.QKSMS.manager.AlarmManagerImpl -import com.moez.QKSMS.manager.AnalyticsManager -import com.moez.QKSMS.manager.AnalyticsManagerImpl -import com.moez.QKSMS.manager.ChangelogManager -import com.moez.QKSMS.manager.ChangelogManagerImpl -import com.moez.QKSMS.manager.KeyManager -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.ShortcutManager -import com.moez.QKSMS.manager.WidgetManager -import com.moez.QKSMS.manager.WidgetManagerImpl +import com.moez.QKSMS.manager.* 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 @@ -58,6 +50,7 @@ import com.moez.QKSMS.mapper.CursorToPart import com.moez.QKSMS.mapper.CursorToPartImpl import com.moez.QKSMS.mapper.CursorToRecipient import com.moez.QKSMS.mapper.CursorToRecipientImpl +import com.moez.QKSMS.mapper.RatingManagerImpl import com.moez.QKSMS.repository.BackupRepository import com.moez.QKSMS.repository.BackupRepositoryImpl import com.moez.QKSMS.repository.BlockingRepository @@ -66,8 +59,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 @@ -81,7 +72,8 @@ import dagger.Provides import javax.inject.Singleton @Module(subcomponents = [ - ConversationInfoComponent::class]) + ConversationInfoComponent::class, + ThemePickerComponent::class]) class AppModule(private var application: Application) { @Provides @@ -93,8 +85,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) } @@ -116,6 +113,9 @@ class AppModule(private var application: Application) { // Manager + @Provides + fun provideBillingManager(manager: BillingManagerImpl): BillingManager = manager + @Provides fun provideActiveConversationManager(manager: ActiveConversationManagerImpl): ActiveConversationManager = manager @@ -140,9 +140,15 @@ class AppModule(private var application: Application) { @Provides fun providePermissionsManager(manager: PermissionManagerImpl): PermissionManager = manager + @Provides + fun provideRatingManager(manager: RatingManagerImpl): RatingManager = manager + @Provides fun provideShortcutManager(manager: ShortcutManagerImpl): ShortcutManager = manager + @Provides + fun provideReferralManager(manager: ReferralManagerImpl): ReferralManager = manager + @Provides fun provideWidgetManager(manager: WidgetManagerImpl): WidgetManager = manager @@ -151,6 +157,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 @@ -177,9 +189,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/java/com/moez/QKSMS/injection/android/BroadcastReceiverBuilderModule.kt b/presentation/src/main/java/com/moez/QKSMS/injection/android/BroadcastReceiverBuilderModule.kt index 7392b44f5ff72fd6016b9d49cd0df4559d8f6b25..77cf22287682ca3cb8d6686ff9fe09a252a7ed8c 100644 --- a/presentation/src/main/java/com/moez/QKSMS/injection/android/BroadcastReceiverBuilderModule.kt +++ b/presentation/src/main/java/com/moez/QKSMS/injection/android/BroadcastReceiverBuilderModule.kt @@ -20,9 +20,11 @@ package com.moez.QKSMS.injection.android import com.moez.QKSMS.feature.widget.WidgetProvider import com.moez.QKSMS.injection.scope.ActivityScope +import com.moez.QKSMS.receiver.BlockThreadReceiver import com.moez.QKSMS.receiver.BootReceiver import com.moez.QKSMS.receiver.DefaultSmsChangedReceiver import com.moez.QKSMS.receiver.DeleteMessagesReceiver +import com.moez.QKSMS.receiver.MarkArchivedReceiver import com.moez.QKSMS.receiver.MarkReadReceiver import com.moez.QKSMS.receiver.MarkSeenReceiver import com.moez.QKSMS.receiver.MmsReceivedReceiver @@ -42,6 +44,10 @@ import dagger.android.ContributesAndroidInjector @Module abstract class BroadcastReceiverBuilderModule { + @ActivityScope + @ContributesAndroidInjector() + abstract fun bindBlockThreadReceiver(): BlockThreadReceiver + @ActivityScope @ContributesAndroidInjector() abstract fun bindBootReceiver(): BootReceiver @@ -54,6 +60,10 @@ abstract class BroadcastReceiverBuilderModule { @ContributesAndroidInjector() abstract fun bindDeleteMessagesReceiver(): DeleteMessagesReceiver + @ActivityScope + @ContributesAndroidInjector + abstract fun bindMarkArchivedReceiver(): MarkArchivedReceiver + @ActivityScope @ContributesAndroidInjector() abstract fun bindMarkReadReceiver(): MarkReadReceiver diff --git a/presentation/src/main/java/com/moez/QKSMS/injection/android/ServiceBuilderModule.kt b/presentation/src/main/java/com/moez/QKSMS/injection/android/ServiceBuilderModule.kt index ddc701bdb96fa495e9cdc1bc08f82640abf66039..ffc30e5c6b0563922c3815cf7b2ea32cdddfe368 100644 --- a/presentation/src/main/java/com/moez/QKSMS/injection/android/ServiceBuilderModule.kt +++ b/presentation/src/main/java/com/moez/QKSMS/injection/android/ServiceBuilderModule.kt @@ -22,12 +22,17 @@ import com.moez.QKSMS.feature.backup.RestoreBackupService import com.moez.QKSMS.injection.scope.ActivityScope import com.moez.QKSMS.service.HeadlessSmsSendService import com.moez.QKSMS.receiver.SendSmsReceiver +import com.moez.QKSMS.service.AutoDeleteService import dagger.Module import dagger.android.ContributesAndroidInjector @Module abstract class ServiceBuilderModule { + @ActivityScope + @ContributesAndroidInjector() + abstract fun bindAutoDeleteService(): AutoDeleteService + @ActivityScope @ContributesAndroidInjector() abstract fun bindHeadlessSmsSendService(): HeadlessSmsSendService @@ -40,4 +45,4 @@ abstract class ServiceBuilderModule { @ContributesAndroidInjector() abstract fun bindSendSmsReceiver(): SendSmsReceiver -} \ No newline at end of file +} diff --git a/presentation/src/main/res/drawable-xxxhdpi/ic_blocking_manager_call_blocker.png b/presentation/src/main/res/drawable-xxxhdpi/ic_blocking_manager_call_blocker.png new file mode 100644 index 0000000000000000000000000000000000000000..0ebdcfa680b296a210ca38698571cea1a3ba60a4 Binary files /dev/null and b/presentation/src/main/res/drawable-xxxhdpi/ic_blocking_manager_call_blocker.png differ diff --git a/presentation/src/main/res/drawable-xxxhdpi/ic_blocking_manager_call_control.png b/presentation/src/main/res/drawable-xxxhdpi/ic_blocking_manager_call_control.png new file mode 100644 index 0000000000000000000000000000000000000000..e7fef9e2149b35c1ca4f322b514b457d7a8d2f14 Binary files /dev/null and b/presentation/src/main/res/drawable-xxxhdpi/ic_blocking_manager_call_control.png differ diff --git a/presentation/src/main/res/drawable-xxxhdpi/ic_blocking_manager_qksms.png b/presentation/src/main/res/drawable-xxxhdpi/ic_blocking_manager_qksms.png new file mode 100644 index 0000000000000000000000000000000000000000..cb53b2df9c27a1d7aab23967b874b46606c1a058 Binary files /dev/null and b/presentation/src/main/res/drawable-xxxhdpi/ic_blocking_manager_qksms.png differ diff --git a/presentation/src/main/res/drawable-xxxhdpi/ic_blocking_manager_sia.png b/presentation/src/main/res/drawable-xxxhdpi/ic_blocking_manager_sia.png new file mode 100644 index 0000000000000000000000000000000000000000..e922a9569a34a9af79f4ff53bbefb3ac2a4e4712 Binary files /dev/null and b/presentation/src/main/res/drawable-xxxhdpi/ic_blocking_manager_sia.png differ 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_archive_black_24dp.xml b/presentation/src/main/res/drawable/ic_archive_white_24dp.xml similarity index 97% rename from presentation/src/main/res/drawable/ic_archive_black_24dp.xml rename to presentation/src/main/res/drawable/ic_archive_white_24dp.xml index 53346afd07e56a62b11adf967f0ff717bea3cb71..9a621d2826566329272bcd91781c34ec6082de04 100644 --- a/presentation/src/main/res/drawable/ic_archive_black_24dp.xml +++ b/presentation/src/main/res/drawable/ic_archive_white_24dp.xml @@ -22,6 +22,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/presentation/src/main/res/drawable/ic_baseline_history_toggle_off_24.xml b/presentation/src/main/res/drawable/ic_baseline_history_toggle_off_24.xml new file mode 100644 index 0000000000000000000000000000000000000000..ece916dcc9a241d33d2761cea5fd0527b1cc57df --- /dev/null +++ b/presentation/src/main/res/drawable/ic_baseline_history_toggle_off_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_pin_black_24dp.xml b/presentation/src/main/res/drawable/ic_baseline_push_pin_24.xml similarity index 76% rename from presentation/src/main/res/drawable/ic_pin_black_24dp.xml rename to presentation/src/main/res/drawable/ic_baseline_push_pin_24.xml index 3602d9d2a0e4280cc8dc58a6b256ace9e321f627..e799af2a85d59d4a20b506fe0174b8e8974d13ce 100644 --- a/presentation/src/main/res/drawable/ic_pin_black_24dp.xml +++ b/presentation/src/main/res/drawable/ic_baseline_push_pin_24.xml @@ -1,5 +1,5 @@ + + android:pathData="M16,9V4l1,0c0.55,0 1,-0.45 1,-1v0c0,-0.55 -0.45,-1 -1,-1H7C6.45,2 6,2.45 6,3v0c0,0.55 0.45,1 1,1l1,0v5c0,1.66 -1.34,3 -3,3h0v2h5.97v7l1,1l1,-1v-7H19v-2h0C17.34,12 16,10.66 16,9z" /> diff --git a/presentation/src/main/res/drawable/ic_block_white_24dp.xml b/presentation/src/main/res/drawable/ic_block_white_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..82499c84b48d974aa7bfd938b858aacd792e597f --- /dev/null +++ b/presentation/src/main/res/drawable/ic_block_white_24dp.xml @@ -0,0 +1,27 @@ + + + + diff --git a/presentation/src/main/res/drawable/compose_bar_background.xml b/presentation/src/main/res/drawable/ic_chevron_right_black_24dp.xml similarity index 69% rename from presentation/src/main/res/drawable/compose_bar_background.xml rename to presentation/src/main/res/drawable/ic_chevron_right_black_24dp.xml index 4c963a1834d0b6c31c1aa20f8383bb58884a7b04..676914f6e88dd4b1b8e4224d6f1209bde7970b39 100644 --- a/presentation/src/main/res/drawable/compose_bar_background.xml +++ b/presentation/src/main/res/drawable/ic_chevron_right_black_24dp.xml @@ -1,4 +1,3 @@ - - - - - - \ No newline at end of file + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/ic_color_lens_black_24dp.xml b/presentation/src/main/res/drawable/ic_color_lens_black_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..f10a26acfacc8825cf8769c9257b7bf746591e70 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_color_lens_black_24dp.xml @@ -0,0 +1,27 @@ + + + + \ 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/about_controller.xml b/presentation/src/main/res/layout/about_controller.xml index 8fe217125cd2cbdf280ff34f6fdbd54a15c9c6ff..22e9fe0d57cb754f8610a30f392ec0b9a9f9c70d 100644 --- a/presentation/src/main/res/layout/about_controller.xml +++ b/presentation/src/main/res/layout/about_controller.xml @@ -1,4 +1,5 @@ - @@ -32,38 +34,18 @@ android:paddingBottom="8dp"> + app:title="@string/about_version_title" + tools:summary="3.0.8" /> - - - - - - - + app:summary="@string/about_developer" + app:title="@string/about_developer_title" /> + + app:summary="@string/about_contact" + app:title="@string/about_contact_title" /> + app:summary="@string/about_license" + app:title="@string/about_license_title" /> . --> + 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/blocking_manager_controller.xml b/presentation/src/main/res/layout/blocking_manager_controller.xml index 03575c7b3bfcf24c69ab54cf9174e4406fadda71..37d90fd15c3d9babc1d59e3ad9c011c6ded84006 100644 --- a/presentation/src/main/res/layout/blocking_manager_controller.xml +++ b/presentation/src/main/res/layout/blocking_manager_controller.xml @@ -30,25 +30,38 @@ android:paddingTop="8dp" android:paddingBottom="8dp"> - + app:title="@string/blocking_manager_qksms_title" + app:widget="@layout/blocking_manager_list_option" /> + + - - diff --git a/presentation/src/main/res/layout/blocking_manager_list_option.xml b/presentation/src/main/res/layout/blocking_manager_list_option.xml index 06d4f8274e926877c47058c259145a714d44d316..079757a17abfb1e511f0293a3c6bb39a1cb993b1 100644 --- a/presentation/src/main/res/layout/blocking_manager_list_option.xml +++ b/presentation/src/main/res/layout/blocking_manager_list_option.xml @@ -18,11 +18,11 @@ ~ along with QKSMS. If not, see . --> + tools:src="@drawable/ic_chevron_right_black_24dp" + tools:tint="?android:attr/textColorTertiary" /> diff --git a/presentation/src/main/res/layout/blocking_manager_preference_view.xml b/presentation/src/main/res/layout/blocking_manager_preference_view.xml new file mode 100644 index 0000000000000000000000000000000000000000..503f5672f085141da03fa60d5586fb7c59111211 --- /dev/null +++ b/presentation/src/main/res/layout/blocking_manager_preference_view.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + diff --git a/presentation/src/main/res/layout/compose_activity.xml b/presentation/src/main/res/layout/compose_activity.xml index 6c7dd1535f7494efde61cdb7178b69751ddb2a31..346a73c51b8378cc92afd87ed3efbdab0e2f6f9c 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" /> @@ -234,7 +213,6 @@ android:layout_width="0dp" android:layout_height="1dp" android:background="?android:attr/divider" - android:elevation="4dp" app:layout_constraintBottom_toTopOf="@id/attachments" app:layout_constraintEnd_toEndOf="@id/messageBackground" app:layout_constraintStart_toStartOf="@id/messageBackground" /> @@ -245,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" @@ -262,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" @@ -281,22 +257,21 @@ android:id="@+id/sim" android:layout_width="44dp" android:layout_height="44dp" - android:background="?attr/selectableItemBackground" + android:background="?android:attr/selectableItemBackgroundBorderless" android:contentDescription="@string/compose_sim_cd" - android:elevation="4dp" - android:padding="10dp" + android:padding="8dp" android:src="@drawable/ic_sim_card_black_24dp" + android:tint="?android:attr/textColorSecondary" android:visibility="gone" app:layout_constraintBottom_toBottomOf="@id/messageBackground" - app:layout_constraintEnd_toStartOf="@id/send" /> + app:layout_constraintEnd_toEndOf="@id/messageBackground" /> + app:layout_constraintEnd_toEndOf="parent" /> + app:constraint_referenced_ids="contact,contactLabel,schedule,scheduleLabel,gallery,galleryLabel,camera,cameraLabel,attachingBackground" /> + tools:backgroundTint="@color/tools_theme" + tools:tint="@color/textPrimaryDark" /> + \ No newline at end of file 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"> - + + + + - - - - + 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..3ded424f196a251156001972e70f02b0b369907d --- /dev/null +++ b/presentation/src/main/res/layout/contacts_activity.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/layout/container_activity.xml b/presentation/src/main/res/layout/container_activity.xml index 4afa326b748c93e273376566785456038f37b1f8..4d24461e81e50ac1306fd038969ed8b662b670d5 100644 --- a/presentation/src/main/res/layout/container_activity.xml +++ b/presentation/src/main/res/layout/container_activity.xml @@ -21,7 +21,7 @@ android:layout_height="match_parent" android:orientation="vertical"> - . --> - - - - - - - - - - - - - - - - - - - - - - - - - \ 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..9fc371aabd963fb7a6636cead389e9db5faa87ac --- /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..70e1a25265e659facaa84af3f0ab150c2961ecfe 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 702656eff5a63518e831db9d3dab6fc0e861fb62..e39863fe629146773663f679ce7181c6de1ac590 100644 --- a/presentation/src/main/res/layout/conversation_recipient_list_item.xml +++ b/presentation/src/main/res/layout/conversation_recipient_list_item.xml @@ -41,7 +41,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" @@ -53,6 +56,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" /> @@ -61,11 +65,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="@color/tools_theme" 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/drawer_view.xml b/presentation/src/main/res/layout/drawer_view.xml index bd33eed581c8c34c33209e4f4242db3bfca80cff..2a419e887bf1b9b4f532f3737557a24259b3e8a7 100644 --- a/presentation/src/main/res/layout/drawer_view.xml +++ b/presentation/src/main/res/layout/drawer_view.xml @@ -18,6 +18,7 @@ ~ along with QKSMS. If not, see . --> + + + + + @@ -170,6 +196,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + - + \ No newline at end of file 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 new file mode 100644 index 0000000000000000000000000000000000000000..bdc1685a7b8aa86f74d473e53faef91df24ef348 --- /dev/null +++ b/presentation/src/main/res/layout/hsv_picker_view.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/main_activity.xml b/presentation/src/main/res/layout/main_activity.xml index bf776b00f609fcfa76a4da42cb6fddee2916edd3..560b00c99a77ad3ed224941cb16178f0d289d6cc 100644 --- a/presentation/src/main/res/layout/main_activity.xml +++ b/presentation/src/main/res/layout/main_activity.xml @@ -52,8 +52,10 @@ 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:inputType="text" + android:maxLines="1" android:paddingStart="16dp" android:paddingEnd="16dp" android:textColorHint="?android:attr/textColorTertiary" @@ -83,6 +85,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 0e92ee0616eab1b3ab4b6055db2d4df76d11037a..0fa090226f157ed5e38e4d65657d9d709abdb6ab 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" /> + android:layout_height="wrap_content" + android:paddingTop="8dp" + android:paddingBottom="8dp"> - \ No newline at end of file + diff --git a/presentation/src/main/res/layout/notification_prefs_activity.xml b/presentation/src/main/res/layout/notification_prefs_activity.xml index befb918fc7fd236941b293542e4aff8f378502ac..407d3b0ff2b95c6784abf0838285acf4a702587b 100644 --- a/presentation/src/main/res/layout/notification_prefs_activity.xml +++ b/presentation/src/main/res/layout/notification_prefs_activity.xml @@ -72,6 +72,13 @@ app:title="@string/settings_notification_previews_title" tools:summary="Show name and message" /> + + + + + + + + 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..daf3e9fd40e822b22715f92bc54a5b10ace1124b --- /dev/null +++ b/presentation/src/main/res/layout/qk_dialog.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + diff --git a/presentation/src/main/res/layout/qkreply_activity.xml b/presentation/src/main/res/layout/qkreply_activity.xml index 308decbe3cae4252351a20e0f9bda323e8e375d9..ee2373a6d3ab977311302038e1751ea956a9789d 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,17 +104,15 @@ 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" app:layout_constraintBottom_toBottomOf="@id/message" - app:layout_constraintEnd_toStartOf="@id/send" /> + app:layout_constraintEnd_toEndOf="@id/messageBackground" /> diff --git a/presentation/src/main/res/layout/scheduled_message_list_item.xml b/presentation/src/main/res/layout/scheduled_message_list_item.xml index 46e6733aeb2c780ba7bcf87fa1e3ce39edfbdcc5..6520ea741edb7c27e97bd345254a7f3642c5fa18 100644 --- a/presentation/src/main/res/layout/scheduled_message_list_item.xml +++ b/presentation/src/main/res/layout/scheduled_message_list_item.xml @@ -25,12 +25,12 @@ android:background="?attr/selectableItemBackground" android:gravity="center_vertical" android:orientation="horizontal" - android:padding="16dp"> + android:padding="12dp"> + + diff --git a/presentation/src/main/res/layout/settings_controller.xml b/presentation/src/main/res/layout/settings_controller.xml index 17faea3aee22f9bbf051a10d70bbbb394d6cf4a7..9efc424bd6487e7b45ee9e4543141bda2b3ccd78 100644 --- a/presentation/src/main/res/layout/settings_controller.xml +++ b/presentation/src/main/res/layout/settings_controller.xml @@ -36,6 +36,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/swipe_actions_controller.xml b/presentation/src/main/res/layout/swipe_actions_controller.xml index 7be0191bee405c63384533f77609e161d00bde3c..a5b706801308807dcc20b4b050b24c32e4f46f1c 100644 --- a/presentation/src/main/res/layout/swipe_actions_controller.xml +++ b/presentation/src/main/res/layout/swipe_actions_controller.xml @@ -104,7 +104,7 @@ app:layout_constraintStart_toStartOf="@id/rightBackground" app:layout_constraintTop_toTopOf="@id/rightBackground" tools:backgroundTint="@color/tools_theme" - tools:src="@drawable/ic_archive_black_24dp" + tools:src="@drawable/ic_archive_white_24dp" tools:tint="@color/textPrimaryDark" /> + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/theme_palette_list_item.xml b/presentation/src/main/res/layout/theme_palette_list_item.xml new file mode 100644 index 0000000000000000000000000000000000000000..1c8ee0cd35c3e273929dfe286f252f065b8bc5dc --- /dev/null +++ b/presentation/src/main/res/layout/theme_palette_list_item.xml @@ -0,0 +1,35 @@ + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/theme_picker_controller.xml b/presentation/src/main/res/layout/theme_picker_controller.xml new file mode 100644 index 0000000000000000000000000000000000000000..7ea2a1ee927a666f75046d735c94d7ad99b7e4bf --- /dev/null +++ b/presentation/src/main/res/layout/theme_picker_controller.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/layout/theme_picker_hsv.xml b/presentation/src/main/res/layout/theme_picker_hsv.xml new file mode 100644 index 0000000000000000000000000000000000000000..3725256242c96e371d7aa5acf9181e9722102f3e --- /dev/null +++ b/presentation/src/main/res/layout/theme_picker_hsv.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/menu/blocked_messages.xml b/presentation/src/main/res/menu/blocked_messages.xml index fffd81cd97b1eb194aa3b8053b855d94ec97ffd0..f938ba89e4c62bce057b76e721f8285f510bdd81 100644 --- a/presentation/src/main/res/menu/blocked_messages.xml +++ b/presentation/src/main/res/menu/blocked_messages.xml @@ -29,7 +29,7 @@ + + - \ No newline at end of file + + + diff --git a/presentation/src/main/res/menu/main.xml b/presentation/src/main/res/menu/main.xml index 15bc85aae96f4225866a02eb8ce3c5408aabd943..32444b2e418f7a3f41fd2afee744a480d7d43b3b 100644 --- a/presentation/src/main/res/menu/main.xml +++ b/presentation/src/main/res/menu/main.xml @@ -1,4 +1,5 @@ - + + تحديد الشريحة %s محددة، غيّر شريحة SIM إرسال الرسالة - الإرسال جارٍ… + الإرسال جارٍ… تم إرسال %s فشل الإرسال. إلمس لإعادة المحاولة التفاصيل + تم نسخ العنوان عنوان المحادثة الإشعارات + السمة الأرشيف إلغاء الأرشفة حظر @@ -171,16 +187,18 @@ الرسالة المجدولة أرسل الآن - Copy text - Delete + نسخ النص + حذف المظهر عام رد QK + السمة الوضع الليلي وضع ليلي بسوادٍ حالك وقت البدء وقت الانتهاء + تلوين جهات الاتصال تلقائيا حجم الخط استخدم خط النظام إيموجي تلقائية @@ -191,6 +209,7 @@ الزر 2 الزر 3 معاينات الإشعارات + فتح الشاشة الاهتزاز الصوت بدون نغمة @@ -208,18 +227,35 @@ لاشيء أرشفة حذف - مكالمة - تعليمها مقروءة - التحديد كمقروء + حظر + اتصل + مقروء + غير مقروء تأكيدات الإستلام تأكيد أنه تم إرسال الرسائل بنجاح توقيع - Add a signature to the end of your messages + اضافة توقيع الى نهاية الرسائل نزع حركات التشكيل حذف حركات التشكيل من المحارف في الرسائل النصية الصادرة أرقام الهواتف النقالة فقط عند إنشاء رسالة، أظهر فقط أرقام الهواتف النقالة + مسح الرسائل القدينة تلقائيا + مسح الرسائل القديمة تلقائيا بعد عدة ايام محددة + عدد الايام + لا نهائيا + مسح الرسائل القديمة تلقائيا؟ + If you proceed, %1$d messages will be deleted now + + بعد يوم واحد + After 1 day + After %d days + After %d days + After %d days + After %d days + + ارسل الرسائل الطويلة كرسائل الوسائط المتعددة + 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 الضغط التلقائي لمرفقات رسائل الوسائط مزامنة الرسائل إعادة مزامنة رسائلك مع قاعدة البيانات الأصلية للرسائل في النظام @@ -235,6 +271,7 @@ مدير المحظورات QKSMS Built-in blocking functionality in QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule Automatically filter your calls and messages in one convenient place! Community IQ™ allows you to prevent unwanted messages from community known spammers الترشيح التلقائي للرسائل من الأرقام غير المرغوبة باستخدام تطبيق \"Should I Answer\" نسخ الارقام المحظورة @@ -272,7 +309,7 @@ سجل التغييرات للاتصال الترخيص - حقوق النشر + حقوق النسخ دعم التطوير و فتح جميع المزايا يمكنك إنقاذ المطوّر الجائع ب %s فقط @@ -281,6 +318,7 @@ افتح + التبرع ل %1$s%2$s شكراً لكم على دعم QKSMS! جميع ميزات QKSMS+ متاحة لك الآن + An error has occurred, please try again QKSMS+ مجاني لمستخدمي متجر ف-درويد! لا تتردد في التبرع إن كنت ترغب دعم تطوير هذا التطبيق. التبرع عن طريق بي بال قريباً @@ -305,20 +343,25 @@ الرد تلقائياً على الرسائل الواردة برسالة محددة مسبقاً المزيد QKSMS قيد التطوير النشط ، مشتراك سيتضمن كل ميزات QKSMS+ المستقبلية! - تحميل… + تحميل… عرض المزيد من المحادثات - علّمها مقروءة + مقروء مكالمة حذف إظهار المزيد إظهار أقل فتح المحادثة + Material + HEX + تطبيق لا شيء - علّمها مقروءة - الرد + ارشيف + مسح + منع اتصال - حذف + مقروء + اجابه نعم الاستمرار @@ -360,18 +403,19 @@ دون تأخير - قصير - متوسط - طويل + ثلاث ثوان + خمس ثوان + عشر ثوان - ١٠٠ ك.ب. - ٢٠٠ ك.ب. - ٣٠٠ ك.ب. (مستحسن) - ٦٠٠ ك.ب. - ١٠٠٠ ك.ب. - ٢٠٠٠ ك.ب. - دون ضغط + تلقائي + 100كيلوبايت + 200كيلوبايت + 300كيلوبايت + 600كيلوبايت + 1000كيلوبايت + 2000كيلوبايت + بدون ضغط حسنًا @@ -394,4 +438,4 @@ معلومات الرسالة سياسة الخصوصية شروط الخدمة - \ No newline at end of file + diff --git a/presentation/src/main/res/values-bn/strings.xml b/presentation/src/main/res/values-bn/strings.xml index 608facf2b0b0c1091eed6edd38e3e25e4e171b71..48224ac152918e2bf21cda1bcdefe49ba8152608 100644 --- a/presentation/src/main/res/values-bn/strings.xml +++ b/presentation/src/main/res/values-bn/strings.xml @@ -20,76 +20,85 @@ --> নতুন আলাপ - কম্পোজ করুন + কম্পোজ করো শর্টকাট নিষ্ক্রিয় আর্কাইভকৃত সেটিংস - নোটিফিকেশন - ইনবক্সে খুঁজুন… - নাম বা নম্বর টাইপ করুন - এড়িয়ে যান - চালিয়ে যান - কল করুন + বিজ্ঞপ্তি + থিম + ইনবক্সে খুঁজো… + নাম বা নম্বর টাইপ করো + এড়িয়ে যাও + আগাও + কাউকে যুক্ত করো + কল করো বিস্তারিত - গ্যালারীতে সেভ করুন - নেভিগেশন ড্রয়ার খুলুন - নির্বাচিত %d - ক্লিয়ার + গ্যালারিতে সংরক্ষণ করো + শেয়ার + ন্যাভিগেশন ড্রয়ার খুলো + %d নির্বাচিত + পরিষ্কার আর্কাইভ আনআর্কাইভ - মুছে ফেলুন - Add to contacts - উপরে থাকুক - সরিয়ে নিন - পড়া হয়েছে - পড়া হয়নি - ব্লক করুন - মেসেজ সিঙ্ক হচ্ছে... - আপনি: %s - Draft: %s - মেসেজের ভিতর পাওয়া গেছে - %d মেসেজ - আপনার আলাপ এখানে দেখা যাবে - রেজাল্ট পাওয়া যায়নি - আপনার আর্কাইভ করা আলাপ এখানে দেখা যাবে - নতুন আলাপ শুরু করুন - আবার texting ভালবাসুন - Message-কে আপনার ডিফল্ট এসএমএস অ্যাপ করুন - পরিবর্তন করুন + মুছে ফেলো + পরিচিততে যুক্ত করো + উপরে রাখো + উপর থেকে সরাও + পড়া হয়েছে + পড়া হয়নি + অবরুদ্ধ + বার্তাগুলি সিঙ্ক হচ্ছে… + তুমি: %s + খসড়া + বার্তার ভিতর পাওয়া গেছে + %dটি বার্তা + তোমার আলাপ এখানে দেখা যাবে + কোনো ফলাফল নেই + তোমার আর্কাইভ করা আলাপ এখানে দেখা যাবে + নতুন আলাপ শুরু করো + আবার বার্তাকে ভালবাসুন + QKSMS-কে তোমার ডিফল্ট এসএমএস অ্যাপ করো + পরিবর্তন করো অনুমতি প্রয়োজন - Message-এর এসএমএস বার্তাগুলো পাঠানো এবং দেখানো অনুমতি - প্রয়োজন - - Message-এর আপনার পরিচিতিগুলি দেখার জন্য অনুমতি - প্রয়োজন - - মঞ্জুর করুন + মেসেজের এসএমএস বার্তাগুলো পাঠানোর এবং দেখার অনুমতি প্রয়োজন + মেসেজের তোমার পরিচিতিগুলি দেখার জন্য অনুমতি প্রয়োজন + মঞ্জুর করো ইনবক্স - আর্কাইভ করা + আর্কাইভকৃত পরিকল্পিত - ব্লক করা - আরও + অবরুদ্ধ + আরো সেটিংস সাহায্য & প্রতিক্রিয়া - বন্ধুদের আমন্ত্রণ জানান - মুছে ফেলুন + বন্ধুদের আমন্ত্রণ জানাও + অসাধারণ সব নতুন বৈশিষ্ট্য পাও এবং উন্নয়নে সাহায্য করো + QKSMS উপভোগ করছো? + আমাদের প্রতি ভালবাসে দেখাতে Google Play-এ আমাদের মূল্যায়ন করো! + ঠিক আছে! + বাতিল করো + মুছে ফেলো - আপনি কি নিশ্চিত যে এই আলাপটি আপনি ডিলিট করতে চান? - আপনি কি নিশ্চিত যে %d টি আলাপ আপনি ডিলিট করতে চান? + তুমি কি নিশ্চিত যে এই আলাপটি তুমি মুছে ফেলতে চাও? + তুমি কি নিশ্চিত যে %d টি আলাপ তুমি মুছতে চাও? - পাঠ্য অনুলিপি করুন - সামনে পাঠান - মুছে ফেলুন + লেখা অনুলিপি করো + অন্যকে পাঠাও + মুছো - নির্বাচিত %d - %2$d এর %1$d ফলাফল - গ্রুপ বার্তা হিসেবে প্রেরণ করুন + ফোন নম্বর নির্বাচন করো + %s ∙ পূর্বনির্ধারিত + মাত্র একবার + সর্বদা + %d নির্বাচিত + %2$d এর %1$d টি ফলাফল + গ্রুপ বার্তা হিসেবে পাঠাও প্রাপক এবং উত্তর সবার চোখে পড়বে - এখানে আপনার কথোপকথন শুরু হয়। সুন্দর কিছু বলুন! + এখানে তোমার আলাপ শুরু হয়। সুন্দর কিছু বলো! পরিচিতি কার্ড জন্য পরিকল্পিত নির্বাচিত সময় অবশ্য ভবিষ্যতে হতে হবে! + পরিকল্পিত বার্তাপ্রেরক ব্যবহার করতে আপনাকে অবশ্যই QKSMS + আনলক করতে হবে পরিকল্পিত বার্তাগুলিতে যোগ করা হয়েছে একটি বার্তা লিখুন… পাঠ্য অনুলিপি করুন @@ -112,22 +121,26 @@ সংযুক্তি যোগ করুন ছবি সংযুক্ত করুন একটি ছবি নিন - পরবর্তীতে পাঠানোর জন্য বার্তা পরিকল্পনা করুন + বার্তা পাঠানোর সময়সূচি নির্ধারণ করো একটি পরিচিতি সংযুক্ত করুন পরিচিতি পড়তে ত্রুটি + + সিম %1$d (%2$s) নির্বাচিত নির্বাচিত, %s সিম কার্ড পরিবর্তন করুন - বার্তাটি প্রেরণ করুন + বার্তা পাঠাও পাঠানো হচ্ছে… প্রেরিত হয়েছে: %s - পাঠাতে ব্যর্থ. আবার চেষ্টা করতে ট্যাপ করুন + পাঠাতে ব্যর্থ। আবার চেষ্টা করতে টিপ দাও বিস্তারিত - কথোপকথনের শিরোনাম + ঠিকানা অনুলিপিত + আলাপের শিরোনাম নোটিফিকেশন + থিম আর্কাইভ আনআর্কাইভ ব্লক করুন আনব্লক করুন - কথোপকথন মুছে ফেলুন + আলাপ মুছে ফেলো মিডিয়া লোড করা যায়নি গ্যালারীতে সেভ করুন ব্যাকআপ ও পুনরুদ্ধার @@ -149,37 +162,39 @@ কোন ব্যাকআপ পাওয়া যায়নি %d মেসেজ - %d মেসেজ গুলো + %dটি মেসেজ এইমুহুর্তে, ব্যাকআপ এবং পুনরুদ্ধার শুধুমাত্র এসএমএস এ করতে পারবেন । এমএমএস সাপর্ট ও শিডিউল ব্যাকআপ শীঘ্রই আসছে! ব্যাকআপ করুন ব্যাক-আপ পার্স করা হচ্ছে… %d/%d বার্তা ব্যাক-আপ সংরক্ষণ করা হচ্ছে… - বার্তা সিঙ্ক করা হচ্ছে… + বার্তাগুলো সিঙ্ক করা হচ্ছে… শেষ হয়েছে! ব্যাকআপ ও পুনরুদ্ধার পরিকল্পিত - স্বয়ংক্রিয়ভাবে একটি বার্তা পাঠান, ঠিক যে মুহূর্তে আপনি চান - হেই! আপনার জন্মদিন যেন কবে? - এটি ২৩শে ডিসেম্বরে - শুভ জন্মদিন! আপনার জন্মদিনকে স্মরণ রাখায় আমি কি আপনার একজন ভাল বন্ধু নই + স্বয়ংক্রিয়ভাবে একটি বার্তা পাঠাও, ঠিক যে মুহূর্তে তুমি চাও + ওই! তোমার জন্মদিন যেন কবে? + ওটা ২৩শে ডিসেম্বরে + শুভ জন্মদিন! দেখো তোমার জন্মদিন মনে রাখায় আমি কতো ভালো একটা বন্ধু ২৩শে ডিসেম্বর পাঠানো হচ্ছে  - পরবর্তীতে পাঠানোর জন্য একটি বার্তা পরিকল্পনা করুন + পরবর্তীতে পাঠানোর জন্য একটি বার্তা পরিকল্পনা করো পরিকল্পিত বার্তা - এখন পাঠান - Copy text - Delete + এখন পাঠাও + লেখা অনুলিপি করো + মুছো উপস্থিতি সাধারণ QK উত্তর + থিম নাইট মোড সম্পূর্ণ কালো রাত্রি মোড শুরুর সময় শেষের সময় + যোগাযোগদের স্বয়ংক্রিয় রং ফন্টের আকার সিস্টেম ফন্ট ব্যবহার করুন স্বয়ংক্রিয় ইমোজি @@ -190,6 +205,7 @@ বাটন ২ বাটন ৩ নোটিফিকেশন প্রিভিউ + স্ক্রিন জাগাও কম্পন শব্দ কোনোটা না @@ -207,54 +223,68 @@ কোনোটা না আর্কাইভ করুন মুছে ফেলুন + অবরুদ্ধ করো কল পঠিত হিসেবে চিহ্নিত করুন না পড়া চিহ্নিত কর ডেলিভারি নিশ্চিতকরন - নিশ্চিত করুন যে বার্তা সফলভাবে পাঠানো হয়েছে + নিশ্চিত করো যে বার্তা সফলভাবে পাঠানো হয়েছে স্বাক্ষর - Add a signature to the end of your messages + তোমার বার্তাগুলোর শেষে একটা স্বাক্ষর যোগ করো কথার টান সরিয়ে দিন - বহির্গামী এসএমএস বার্তার অক্ষর থেকে কথার টান সরিয়ে দিন + বহির্গামী এসএমএস বার্তার অক্ষর থেকে কথার টান সরিয়ে নাও(accent) শুধু মোবাইল নম্বর - বার্তা লিখার করার সময়, শুধুমাত্র মোবাইল নম্বর দেখান + বার্তা লিখার করার সময়, শুধুমাত্র মোবাইল নম্বর দেখাও + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After 1 day + After %d days + + দীর্ঘ বার্তা এমএমএস হিসেবে পাঠাও + যদি তোমার দীর্ঘ বার্তা পাঠানো ব্যর্থ হয় বা ভুল ক্রমানুসারে পাঠানো হয় তবে তুমি তার পরিবর্তে বার্তাগুলো এমএমএস বার্তা হিসাবে প্রেরণ করতে পারো। অতিরিক্ত চার্জ প্রযোজ্য হতে পারে এমএমএসের সংযুক্তি সক্রিয়ভাবে সংকোচন করুন বার্তাগুলি সিঙ্ক করুন আপনার বার্তাগুলি অ্যান্ড্রয়েডের নিজস্ব এসএমএস এর ডাটাবেস সাতে পুনরায় সিঙ্ক করুন - 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 - স্বয়ংক্রিয়ভাবে \"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 + মেসেজের অন্তর্ভুক্ত অবরোধী সক্ষমতা(blocking) + Block spam messages, numbers & unknown calls with blacklist & Schedule + তোমার কল আর বার্তা একটাই সুবিধাজনক জায়গা থেকে ফিল্টার করো বা ছাঁকো! Community IQ™ দিয়ে অবাঞ্ছিত বার্তা আটকানো যায় সমাজে পরিচিত স্প্যামারদের থেকে + স্বয়ংক্রিয়ভাবে \"Should I Answer\"(আমার কি উত্তর দেয়া উচিত?) অ্যাপ্লিকেশন ব্যবহার করে অযাচিত নম্বর-এর বার্তাগুলি ফিল্টার করো + অবরুদ্ধ নম্বরগুলো অনুলিপি করো + %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-এ এগিয়ে যাও ও এই নম্বরগুলো থেকে বার্তা আসতে দাও সম্পর্কে সংস্করণ @@ -272,6 +302,7 @@ %1$s %2$s দান করুন, + আনলক করুন QKSMS সমর্থনের জন্য আপনাকে ধন্যবাদ! আপনি এখন QKSMS+-এর সব বৈশিষ্ট্যেের সুবিধা পাচ্ছেন + An error has occurred, please try again F-Droid ব্যবহারকারীরা QKSMS+ বিনামূল্যে ব্যবহার করতে পারবেন! যদি আপনি উন্নয়ন সমর্থন করতে মুক্ত মনে অনুদান করুন। পেপাল এর মাধ্যমে দান করুন শীঘ্রই আসছে @@ -279,39 +310,44 @@ Material Design প্যালেট পাওয়া যায় না এমন সুন্দর রঙয়ের থীম আনলক করুন কাস্টম সক্রিয়-ইমোজি কাস্টম সক্রিয়-ইমোজি শর্টকাট তৈরি করুন - বার্তা ব্যাক আপ - আপনার বার্তাগুলো স্বয়ংক্রিয়ভাবে ব্যাক আপ করুন। এবারআপনার ফোন পরিবর্তন বা আপনার ফোন নষ্ট হওয়ায় আপনাকে কখনো এসএমএস ইতিহাস হারানোর বিষয়ে উদ্বিগ্ন হতে হবে না + বার্তাগুলোর ব্যাকআপ + তোমার বার্তাগুলো স্বয়ংক্রিয়ভাবে ব্যাকআপ করো। এবার তোমার ফোন পরিবর্তন বা নষ্ট হওয়ায় এসএমএস-এর ইতিহাস হারানো নিয়ে কখনো উদ্বিগ্ন হতে হবে না পরিকল্পিত বার্তাগুলি - পরিকল্পিত বার্তাগুলি স্বয়ংক্রিয়ভাবে একটি নির্দিষ্ট সময় ও তারিখ প্রেরণ করা হবে - বিলম্বে পাঠানো - আপনার বার্তা পাঠানোর আগে কয়েক সেকেন্ড অপেক্ষা করুন + পরিকল্পিত বার্তাগুলি স্বয়ংক্রিয়ভাবে একটি নির্দিষ্ট সময় ও তারিখে প্রেরণ করা হবে + বিলম্বে পাঠাও + তোমার বার্তা পাঠানোর আগে কয়েক সেকেন্ড অপেক্ষা করো স্বয়ংক্রিয় রাত্রি মোড দিন সময় উপর ভিত্তি করে নাইট মোড সক্ষম করুন উন্নীত আনব্লক করা - সেই সব বার্তা ব্লক কারুন যার কোন শব্দ বা নমুনার সাথে মিলে + সেই সব বার্তা ব্লক(অবরোধ) করো যা কোন শব্দ বা নমুনার সাথে মিলে সক্রিয়ভাবে সামনে পাঠান - নির্দিষ্ট প্রেরকের বার্তাগুলো স্বয়ংক্রিয়ভাবে সামনে পাঠান + নির্দিষ্ট প্রেরকের বার্তাগুলো স্বয়ংক্রিয়ভাবে সামনে পাঠাও(ফরওয়ার্ড) স্বয়ংক্রিয়-উত্তর - আগত বার্তা জন্য স্বয়ংক্রিয়ভাবে পূর্বনির্ধারিত প্রতিক্রিয়া পাঠান + আগত বার্তার জন্য স্বয়ংক্রিয়ভাবে পূর্বনির্ধারিত প্রতিক্রিয়া পাঠাও আরও QKSMS সক্রিয় উন্নয়ন অধীনে এবং QKSMS+ এর সকাল ভবিষ্যত ফিচার আপনার কেনা অ্যাপেে অন্তর্ভুক্ত থাকবে! লোড হচ্ছে… - আরও কথোপকথন প্রদর্শন করুন - পড়া চিহ্নিত করুন + আরও আলাপ দেখো + পড়া হয়েছে কল করুন মুছে ফেলুন আরও দেখান আরও কম প্রদর্শন করুন - চলমান কথোপকথন + আলাপ খুলো + উপকরণ + হেক্স + প্রয়োগ করুন কোনোটা না - পঠিত হিসেবে চিহ্নিত করুন - উত্তর দিন + আর্কাইভ + মুছুন + অবরুদ্ধ কল - মুছে ফেলুন + পড়া হয়েছে + প্রতুত্তর - Yes - Continue + হ্যাঁ + এগিয়ে যানও বাতিল করুন মুছে ফেলুন সেভ করুন @@ -320,13 +356,14 @@ সেট করুন বাতিল করো অনুলিপি করা হয়েছে - আর্কাইভ করা কথোপকথন + আর্কাইভ করা আলাপ + এই ব্যবহার করতে আপনাকে অবশ্যই QKSMS + আনলক করতে হবে - %s নতুন বার্তা - নতুন বার্তা + নতুন বার্তা + %sটি নতুন বার্তা - বার্তা পাঠাতে অক্ষম - %s এর বার্তাটি পাঠাতে ব্যর্থ হয়েছে + বার্তা পাঠাতে ব্যর্থ + %s এর কাছে বার্তাটি পাঠাতে ব্যর্থ পদ্ধতি অক্ষম @@ -334,7 +371,7 @@ স্বয়ংক্রিয় - নাম এবং বার্তা প্রদর্শন করুন + নাম এবং বার্তা প্রদর্শন করো নাম দেখান কন্টেন্ট আড়াল করুন @@ -346,18 +383,19 @@ কোনো বিলম্ব নেই - সংক্ষিপ্ত - মাঝারি - দীর্ঘ + ৩ সেকেন্ড + ৫ সেকেন্ড + ১০ সেকেন্ড - ১০০KB - ২০০KB - ৩০০KB (সুপারিশ করা) - ৬০০KB - ১০০০KB - ২০০০KB - সংকোচনহীন + স্বয়ংক্রিয় + ১০০কিবা + ২০০কিবা + ৩০০কিবা + ৬০০কিবা + ১০০০কিবা + ২০০০কিবা + সংকোচন নেই(compression) ঠিক আছে @@ -370,7 +408,7 @@ না ভালোবাসি তোমাকে দুঃখিত - হেহে - তা ঠিক। + হাহা + তা ঠিক আছে। diff --git a/presentation/src/main/res/values-cs/strings.xml b/presentation/src/main/res/values-cs/strings.xml index a123cd38d795e1f72364b5fdd540564279cedd2f..3923febd3416ccf5d5b02be173d08ad6590c2d15 100644 --- a/presentation/src/main/res/values-cs/strings.xml +++ b/presentation/src/main/res/values-cs/strings.xml @@ -1,5 +1,4 @@ - + Vybrána SIM %1$d (%2$s) %s vybráno, změňte SIM kartu Odeslat zprávu Odesílání… Doručeno %s Odeslání se nezdařilo. Klepnutím zkusíte znovu odeslat Podrobnosti + Adresa zkopírována Název konverzace Oznámení Archivovat Obnovit z archivu Blokovat Odblokovat - Odstranit konverzaci + Smazat konverzaci Soubor nelze načíst Uloženo do galerie Záloha a obnovení @@ -183,6 +191,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 @@ -193,6 +202,7 @@ Tlačítko 2 Tlačítko 3 Nastavení oznámení + Rozsvítit displej Vibrace Vyzvánění Žádný @@ -210,8 +220,9 @@ Žádná Archivovat Smazat + Blokovat Volat - Přečteno + Označit jako přečtené Označit jako nepřečtené Potvrzení o doručení @@ -222,6 +233,20 @@ Odstranit diakritiku v odesílaných SMS zprávách Pouze mobilní čísla Při psaní zprávy zobrazit pouze mobilní telefonní čísla + Automaticky odstranit staré zprávy + Zprávy budou odstraněny po zadaném počtu dní + Počet dní + Nikdy + Odstranit staré zprávy automaticky? + Budete-li pokračovat, bude nyní odstraněno %1$d zpráv + + Po 1 dni + Po %d dnech + Po %d dnech + Po %d dnech + + 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í @@ -237,6 +262,7 @@ Správce blokování QKSMS Vestavěné blokování v QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule Automatické filtrování hovorů a zpráv na jednom místě. Community IQ™ poskytuje ochranu před nevyžádanými zprávami od komunitou označených spammerů. Automaticky filtrovat zprávy od nevyžádaných čísel pomocí aplikace „Můžu to zvednout?“ Kopírovat blokovaná čísla @@ -279,6 +305,7 @@ Odemknout + darovat %1$s %2$s Děkujeme za podporu QKSMS! Nyní máte přístup ke všem funkcím QKSMS+ + An error has occurred, please try again QKSMS + je zdarma pro uživatele F-Droid! Pokud byste chtěli podpořit vývoj, neváhejte přispět. Darovat pomocí PayPal Připravujeme @@ -312,15 +339,17 @@ Otevřít konverzaci Žádné - Přečteno - Odpovědět - Volat + Archivovat Smazat + Blokovat + Volat + Označit jako přečtené + Odpovědět Ano Pokračovat Zrušit - Odstranit + Smazat Uložit Zastavit Více @@ -355,18 +384,19 @@ Bez prodlevy - Krátká - Střední - Dlouhá + 3 sekundy + 5 sekund + 10 sekund + 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 8b0ba7f69577b08256e43064f76ed124993c14f8..2f104ddf47b8e48d56fc34c0a5275f7c5eb56140 100644 --- a/presentation/src/main/res/values-da/strings.xml +++ b/presentation/src/main/res/values-da/strings.xml @@ -1,5 +1,4 @@ - - Ny samtale + Åbn samtale Skriv Genvej deaktiveret Arkiveret @@ -29,9 +28,11 @@ Angiv et navn eller nummer Overspring Fortsæt + Tilføj person Opkald Oplysninger Gem i Galleri + Del Åbn navigeringsskuffe %d valgt Ryd @@ -46,7 +47,7 @@ Blokér Synkroniserer beskeder… Dig: %s - Udkast: %s + Udkast Resultater i beskeder %d beskeder Dine samtaler vil fremgå her @@ -78,6 +79,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 @@ -111,12 +116,15 @@ Planlæg besked Vedhæft en kontakt Fejl ved læsning af kontakt + + SIM %1$d (%2$s) valgt %s valgt, skift SIM-kortet Send besked Afsender… Leveret %s Mislykkedes at afsende. Tryk for at forsøge igen Oplysninger + Adresse kopieret Samtaletitel Notifikationer Arkiv @@ -175,6 +183,7 @@ Ren sort nattilstand Starttidspunkt Sluttidspunkt + Automatiske kontaktfarver Skriftstørrelse Brug systemets skrifttype Automatisk emoji @@ -185,6 +194,7 @@ Knap 2 Knap 3 Notifikationseksempler + Væk skærm Vibration Lyd Ingen @@ -202,6 +212,7 @@ Ingen Arkivér Slet + Blokér Ring op Markér som læst Markér som ulæst @@ -214,6 +225,18 @@ Fjern betoninger fra tegn i udgående SMS\'er Kun mobilnumre Vis kun mobilnumre ved SMS-skrivning, + Slet automatisk gamle beskeder + Beskeder slettes efter det angivne antal dage + Antal dage + Aldrig + Slet automatisk gamle beskeder? + Fortsætter du, slettes %1$d beskeder nu + + Efter 1 dag + Efter %d dage + + 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 @@ -229,6 +252,7 @@ Blokeringshåndtering QKSMS Indbygget blokeringsfunktionalitet i QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule Filtrér automatisk dine opkald og beskeder på ét praktisk sted! Community IQ ™ giver dig mulighed for at forhindre uønskede beskeder fra spammere kendt af fællesskabet Filtrér automatisk beskeder fra uopfordrede numre vha. \"Bør jeg besvare\"-app\'en Kopiér blokerede numre @@ -267,6 +291,7 @@ Oplås + donér for %1$s %2$s Tak for din støtte til QKSMS! Du har nu adgang til alle QKSMS+-funktioner + An error has occurred, please try again QKSMS+ er gratis for F-Droid-brugere! Ønsker du at støtte udviklingen, er du velkommen til at donere. Donér via PayPal Kommer snart @@ -300,10 +325,12 @@ Åbn samtale Ingen - Markér som læst - Besvar - Ring op + Arkivér Slet + Blokér + Ring op + Markér som læst + Svar Ja Fortsæt @@ -341,18 +368,19 @@ Ingen forsinkelse - Kort - Mellem - Lang + 3 sekunder + 5 sekunder + 10 sekunder - 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-es/strings.xml b/presentation/src/main/res/values-es/strings.xml index 047ae2fd5150dd5d7a276661be3d39fe376bb03f..4c53c4eef7067f8efbf44a49d7b4890d3e13356c 100644 --- a/presentation/src/main/res/values-es/strings.xml +++ b/presentation/src/main/res/values-es/strings.xml @@ -1,5 +1,5 @@ - + SIM %1$d (%2$s) seleccionada %s seleccionado, cambia la tarjeta SIM Enviar mensaje Enviando… Entregado %s - No se envió. Pulse para volver a intentarlo + No se pudo enviar. Pulse para volver a intentarlo Detalles + Dirección copiada Título de la conversación Notificaciones Archivar @@ -163,8 +172,8 @@ Mensaje programado Enviar ahora - Copy text - Delete + Copiar texto + Borrar Apariencia General @@ -173,6 +182,7 @@ Modo noche negro puro Hora de inicio Hora de finalización + Colores de contacto automáticos Tamaño de la fuente Usar fuente del sistema Emojis automáticos @@ -183,6 +193,7 @@ Botón 2 Botón 3 Previsualización de notificaciones + Encender pantalla Vibración Sonido Ninguno @@ -200,18 +211,31 @@ Ninguno Archivar Eliminar + Bloquear Llamar Marcar como leído Marcar como no leído - Confirmaciones de envió + Confirmaciones de envío Confirmar cuando los mensajes se envien con éxito Firma - Add a signature to the end of your messages + Añadir una firma al final de tus mensajes Detalles en tiras 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 + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After 1 day + After %d days + + Enviar mensajes largos como MMS + Si sus mensajes de texto largos no se están enviando o enviando en el orden incorrecto, puede enviarlos como mensajes MMS en su lugar. Se pueden aplicar cargos adicionales Autocomprimir archivos adjuntos MMS Sincronizar mensajes Vuelve a sincronizar tus mensajes con la base de datos nativa de Android SMS @@ -220,34 +244,35 @@ Registro de depuración activado Registro de depuración desactivado Introduzca la duración (segundos) - 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 + Bloqueando + Borrar mensajes + Eliminar mensajes entrantes de los remitentes bloqueados en lugar de ocultarlos + Conversaciones bloqueadas + Gestor de bloqueo + Message + Función de bloqueo integrado en QKSMS + Bloquee mensajes de spam, números y llamadas desconocidas con lista negra y programación + ¡Filtrre automáticamente sus llamadas y mensajes en un lugar conveniente! La comunidad IQ™ te permite prevenir mensajes no deseados de spammers conocidos por la comunidad Filtra automáticamente los mensajes de números no solicitados mediante la aplicación \"¿Debo responder?\" - 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 + Copiar números bloqueados + Continúa a %s y copia sobre tus números bloqueados existentes + Números bloqueados + Tus números bloqueados aparecerán aquí + Bloquear un nuevo número + Bloquear textos de + Número de teléfono + Bloquear + Mensajes bloqueados + Tus mensajes bloqueados aparecerán aquí + Bloquear + Desbloquear - Continue to %s and block this number - Continue to %s and block these numbers + Continúa a %s y bloquea este número + Continúa a %s y bloquea estos números - Continue to %s and allow this number - Continue to %s and allow these numbers + Continuar a %s y permitir este número + Continuar a %s y permitir estos números Acerca de Versión @@ -265,6 +290,7 @@ Desbloquear + donación %1$s %2$s Gracias por apoyar a QKSMS! Ahora tienes acceso a todas las funciones de QKSMS + + An error has occurred, please try again QKSMS+ es gratis para los usuarios de F-Droid! Si desea apoyar el desarrollo, no dude en hacer una donación. Donar con PayPal Próximamente @@ -298,13 +324,15 @@ Abrir conversación Ninguno + Archivar + Eliminar + Bloquear + Llamar Marcar como leído Responder - Llamar - Eliminar - Yes - Continue + + Continuar Cancelar Eliminar Guardar @@ -321,10 +349,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 @@ -339,14 +367,15 @@ Sin demora - Corto - Mediano - Largo + 3 segundos + 5 segundos + 10 segundos + Automático 100KB 200KB - 300KB (recomendado) + 300KB 600KB 1000KB 2000KB @@ -367,4 +396,4 @@ Está bien Mensajes - \ No newline at end of file + diff --git a/presentation/src/main/res/values-fa/strings.xml b/presentation/src/main/res/values-fa/strings.xml index bbaf2de878530f524a393372587a460306c0d66f..439fd0e3381a47e05a33f1d0cd39a873ea49c94a 100644 --- a/presentation/src/main/res/values-fa/strings.xml +++ b/presentation/src/main/res/values-fa/strings.xml @@ -21,37 +21,40 @@ گفتگوی جدید نوشتن - میانبر غیر فعال است - بایگانی + میانبر غیرفعال شد + بایگانی شد تنظیمات اعلان‌ها - یافتن پیامک… + پوسته + جستجو در صندوق ورودی… یک نام یا شماره را وارد کنید - بیخیال + رد شدن ادامه + افزودن شخص تماس جزئیات ذخیره در گالری + هم‌رسانی بازکردن کشو ناوبری %d انتخاب شده پاکسازی بایگانی بیرون آوردن از بایگانی حذف - Add to contacts - اتصال به بالای صفحه - Unpin - علامت گذاری به عنوان خوانده شده - علامت گذاری بعنوان خوانده نشده + افزودن به مخاطبین + سنجاق به بالای صفحه + رها کردن + نشان به‌عنوان خوانده شده + نشان به‌عنوان خوانده نشده مسدود کردن - همگام سازی پیام ها… + در حال همگام‌سازی پیام‌ها… شما: %s - Draft: %s - نتایج در پیام ها + پیش‌نویس + نتایج در پیام‌ها %d پیام - مکالمات اینجا نمایش داده می‌شود + گفتگوهایتان اینجا نمایش داده می‌شود بدون نتیجه - بایگانی اینجا نمایش داده می‌شود + بایگانی‌هایتان اینجا نمایش داده می‌شود شروع گفتگوی جدید Love texting again Message را به عنوان نرم‌افزار پیش‌فرض پیامک انتخاب @@ -63,35 +66,45 @@ Message needs permission to view your contacts اجازه صندوق ورودی - بایگانی - زمان بندی + بایگانی شده + زمان‌بندی شده مسدود بيشتر تنظیمات راهنما & بازخورد دعوت دوستان + Unlock amazing new features, and support development + از QKsms لذت می برید؟ + علاقه ی خود رابا امتیاز دادن به ما در گوگل پلی به اشتراک بگذارید + باشه! + رد حذف آیا مطمئن هستید که می خواهید این گفتگو را حذف کنید؟ - آیا مطمئن هستید که می خواهید این %d گفتگو را حذف کنید؟ + آیا مطمئن هستید که می خواهید %d گفتگو را حذف کنید؟ کپی متن - فوروارد + هدایت حذف + یک شماره تلفن انتخاب کنید + %s ∙ پیش‌فرض + فقط یک بار + همیشه %d انتخاب شده - %1$d of %2$d results - ارسال پیام به گروه - Recipients and replies will be visible to everyone - This is the start of your conversation. Say something nice! + نتایج %1$d از %2$d + ارسال به‌صورت پیام گروهی + گیرندگان و پاسخ‌ها برای همه قابل مشاهده خواهد بود + این آغاز گفتگوی شماست. یه چیز خوب بگو! کارت تماس - Scheduled for - Selected time must be in the future! - Added to scheduled messages + برنامه‌ریزی شده برای + زمان انتخابی باید در آینده باشد! + برای استفاده از پیام‌های برنامه‌ریزی شده ، باید QKSMS+ را باز کنید + به پیام‌های برنامه‌ریزی شده افزوده شد نوشتن پیام… - کپی کردن متن - Forward + کپی‌کردن متن + هدایت حذف قبلی بعدی @@ -109,206 +122,234 @@ کد خطا: %d افزودن پیوست پیوست یک عکس - Take a photo - زمان بندی پیام - Attach a contact - Error reading contact - %s برگزیده شده، تغییر سیم کارت + گرفتن عکس + زمان‌بندی پیام + یک مخاطب پیوست کنید + خطا در خواندن مخاطب + + سیم‌کارت %1$d (%2$s) انتخاب شد + %s انتخاب شد، سیم‌کارت را عوض کنید فرستادن پیام - فرستادن… + در حال فرستادن… رسیده %s - در ارسال خطایی رخ داد. برای ارسال دوباره، ضربه بزنید + ارسال ناموفق بود. برای تلاش دوباره ضربه بزنید جزئیات + نشانی کپی شد عنوان گفتگو - اعلانها - بایگانی - بدون موضوع + اعلان‌ها + پوسته + بایگانی کن + بیرون آوردن از بایگانی مسدود کردن رفع مسدودیت - حذف مکالمه - Couldn\'t load media - ذخیره در گالری - پشتیبان گیری و بازیابی - پشتیبان گیری از پیام‌ها + حذف گفتگو + نمی‌توان رسانه را بارگذاری کرد + در گالری ذخیره شد + پشتیبان‌گیری و بازیابی + پشتیبان‌گیری از پیام‌ها بازگرداندن پشتیبان اخرین پشیبان گیری - بارگذاری… + در حال بارگذاری… هرگز - بازگردانشانی - یک پشتیبان را انتخاب کنید - Backup in progress… - Restore in progress… - Restore from backup - آیا مطمئن هسیتد که می‌خواهید پیغام‌هایتان را از پشتيباني بازیابی کنید? - بازیابی را متوقف کن - Messages that have already been restored will remain on your device + بازگردانی + یک پشتیبان را برگزینید + لطفاً QKSMS + را باز کنید تا از امکانات پشتیبان و بازیابی بهره ببرید + پشتیبان‌گیری در حال انجام است… + بازگردانی در حال انجام است… + بازگردانی از نسخه پشتیبان + آیا مطمئن هسیتد که می‌خواهید پیام‌هایتان را از این پشتيبان بازگردانی کنید؟ + بازگردانی را متوقف کن + پیام‌هایی که قبلا بازگردانی شده‌اند در دستگاه شما باقی خواهند ماند پشتیبان‌ها - نسخه ی پشتیبان پیدا نشد + نسخه پشتیبانی یافت نشد - %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 + در حال ذخیره پشتیبان… + در حال همگام‌سازی پیام‌ها… + تمام شد! + پشتیبان‌گیری و بازگردانی + برنامه‌ریزی شده + در همان لحظه ای که می خواهید پیام را به‌صورت خودکار ارسال کنید + سلام! راستی تولدت کی بود؟ + روز ۲۳ دسامبر + تولدت مبارک! ببین چه دوست خوبیم، به یاد تولدتم - Sending on December 23rd  - Schedule a message - Scheduled message + ارسال در ۲۳ دسامبر + یک پیام را برنامه‌ریزی کنید + پیام برنامه‌ریزی شده - ارسال هم‌اکنون - Copy text - Delete + هم اکنون بفرست + کپی متن + حذف ظاهر عمومی - QK Reply + پاسخ سریع + پوسته حالت شب حالت شب کامل - زمان شروع + زمان آغاز زمان پایان + رنگ مخاطب خودکار اندازه قلم از فونت سیستم استفاده کن - ایموجی خودکار + شکلک خودکار اعلان‌ها - از فونت سیستم استفاده کن - Actions - Button 1 - Button 2 - Button 3 - بازبینی اعلان + لمس برای شخصی سازی + اقدامات + دکمه ۱ + دکمه ۲ + دکمه ۳ + پیش نمایش اعلان + صفحه بیدار لرزش - صدای + صدا هیچی - پاسخ QK + پاسخ سریع پنجره پیام های جدید برای رد کردن ضربه بزنید - Tap outside of the popup to close it - ارسال با تاخیر - Swipe actions + برای بستن آن ، به بیرون پنجره ضربه بزنید + ارسال با درنگ + اقدامات کشیدن تنظیم کشیدن برای گفتگو به راست بکشید به چپ بکشید تغییر هیچی - آرشیو + بایگانی پاک کردن + مسدود کردن تماس - خوانده شده - علامت گذاری بعنوان خوانده نشده + نشان به‌عنوان خوانده شده + نشان به‌عنوان خوانده نشده تأیید تحویل - تأیید ارسال موفق پیام + تأیید کن که پیام‌ها با موفقیت ارسال شدند امضا - Add a signature to the end of your messages + به انتهای پیام های خود یک امضا اضافه کنید حذف لهجه‌ها حذف کاراکترهای اضافه پیام ارسالی فقط شماره های تلفن وقتی در حال ارسال پیام هستید فقط می توانید شماره تلفن را ببنید + حذف خودکار پیام های قدیمی + پیام ها پس از تعداد روز تعیین شده حذف می شوند + تعداد روزها + هرگز + پیام های قدیمی به طور خودکار حذف شوند؟ + اگر ادامه دهید، اکنون %1$d پیام حذف خواهد شد + + بعد از ۱ روز + بعد از %d روز + + پیام های طولانی را به عنوان MMS ارسال کن + اگر پیام های متنی طولانی شما نتوانسته اند ارسال شوند یا به ترتیب اشتباه ارسال شوند ، می توانید به جای آنها پیام های MMS ارسال کنید. هزینه های اضافی ممکن است اعمال شود فشرده سازی خودکار پیوست ها - همگام سازی پیام ها - همگام سازی پیام ها با پایگاه داده - در مورد Message - نسخه ی %s - ورود به سیستم دیباگ فعال است - ورد به سیستم دیباگ غیرفعال است - زمان (ثانیه) را وارد کنید - Blocking - Drop messages - Drop incoming messages from blocked senders instead of hiding them - Blocked conversations - Blocking Manager + همگام‌سازی پیام‌ها + همگام‌سازی مجدد پیام‌ها با پایگاه‌داده بومی پیامک اندروید + درباره QKSMS + نگارش %s + گزارش‌گیری اشکال‌زدایی فعال است + گزارش‌گیری اشکال‌زدایی غیرفعال است + مدت زمان را وارد کنید (ثانیه) + انسداد + پیام‌ها را دور بینداز + پیام های ورودی را از فرستنده های مسدود شده به جای مخفی کردن آنها دور بیندازید + گفتگوهای مسدود شده + مدیریت انسداد 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 - با استفاده از برنامه «باید جواب بده»، پیامها را از شماره های ناخواسته به طور خودکار فیلتر کنید - 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 + عملکرد مسدود کننده داخلی در QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule + تماس ها و پیام های خود را بطور خودکار فیلتر کنید! IQ Community به شما اجازه می دهد تا از پیام های ناخواسته توسط اسپمرهای شناخته شده در جامعه جلوگیری کنید + با استفاده از برنامه «آیا باید جواب بدم»، پیام‌ها را از شماره های ناخواسته به طور خودکار فیلتر کنید + کپی شماره های مسدود شده + به %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 + گزارش دگرگونی‌ مخاطب مجوز حق نشر حمایت از تولید کننده،بازشدن همه چیز شما میتوانید با %s از ما حمایت کنید - زمان توسعه %1$s %2$s + ارتقا مادام العمر به‌ازای %1$s %2$s - نسخه پیشرفته و حمایت %1$s %2$s - ممنون از حمایت شما! - حال شما به همه امکانات دسترسی دارید - QKSMS + برای کاربران F-Droid رایگان است! اگر میخواهید از توسعه حمایت کنید، احساس رایگان کنید تا کمک مالی شود. + نسخه پیشرفته و حمایت به‌ازای %1$s %2$s + سپاس از شما برای حمایت از QKSMS! + حال شما به همه امکانات QKSMS+ دسترسی دارید + An error has occurred, please try again + QKSMS+ برای کاربران F-Droid رایگان است! اگر می‌خواهید از توسعه حمایت کنید، از کمک مالی بسیار قدردانی می‌شود. از طریق PayPal کمک مالی کنید به زودی - پوسته پیشرفته + پوسته‌های ویژه در حالت متریال دیزاین رنگبندی پوسته امکانپذیر نیست شکلک خودکار سفارشی - ساخت میانبرهای شخصی برای شکلک اتوماتیک - پشتیبان‌گیری پیامها + ساخت میانبرهای سفارشی برای شکلک خودکار + پشتیبان‌گیری پیام‌ها از پیام‌هایتان به‌طور خودکار پشتیبان بگیرید. دیگر نگران از دست دادن پیام ها نباشید وقتی تلفن همراهتان را عوض می‌کنید یا از دست می‌دهید پیام های برنامه ریزی شده - پیام های برنامه ریزی شده به طور خودکار در یک زمان و تاریخ خاص ارسال می شود - تاخیر در ارسال - لطفا چند ثانیه صبر کنید - حالت شب اتوماتیک + پیام‌ها را برنامه‌ریزی کنید تا در یک زمان و تاریخ خاص به طور خودکار ارسال شوند + ارسال با درنگ + قبل از ارسال پیامتان چند ثانیه صبر کنید + حالت شب خودکار فعال کردن حالت شب براساس زمان روز مسدود کردن پیشرفته - هرزنامه ها وپیام های بد را مسدود کن + پیام‌های دارای کلیدواژه‌ها یا مطابق با الگوها را مسدود کن هدایت خودکار هدایت کردن پیام از فرستندگان خاص پاسخ خودکار - ارسال جواب خودکار به پیامک ها + پاسخ خودکار به پیام‌های دریافتی با پاسخ از پیش تعیین شده بیشتر QKSMS در حال توسعه فعال است، و خرید شما شامل تمام ویژگی های آینده QKSMS + نیز هست ! - بارگذاری… - مشاهده مکالمات بیشتر - خوانده شده + درحال بارگذاری… + مشاهده گفتگوهای بیشتر + نشان به‌عنوان خوانده شده تماس پاک کردن بیشتر نمایش بده کمتر نمایش بده - بازکردن پیام + بازکردن گفتگو + متریال + هگز + اعمال کردن هیچ - علامت گذاری به عنوان خوانده شده - پاسخ + بایگانی کن + حذف + مسدود کردن تماس - پاک کردن + نشان به‌عنوان خوانده شده + پاسخ - Yes - Continue + بله + ادامه لغو حذف ذخیره @@ -318,12 +359,13 @@ برگرده کپی شد گفتگوی بایگانی شده + باید نسخه پیشرفته را فعال کنید پیام جدید پیام جدید - ارسال نشد - پیام به٪ s فرستاده نشد + پیام ارسال نشد + پیام به %s فرستاده نشد سامانه غیرفعال @@ -342,19 +384,20 @@ بزرگتر - بدون تاخیر - کوتاه - متوسط - طولانی + بدون درنگ + ۳ ثانیه + ۵ ثانیه + ۱۰ ثانیه - ۱۰۰KB - ۲۰۰KB - 300 کیلوبایت(توصیه شده) - ۶۰۰KB - ۱۰۰۰KB - ۲۰۰۰KB - بدون فشردگی + خودکار + ۱۰۰ کیلوبایت + ۲۰۰ کیلوبایت + ۳۰۰ کیلوبایت + ۶۰۰ کیلوبایت + ۱۰۰۰ کیلوبایت + ۲۰۰۰ کیلوبایت + بدون فشرده‌سازی باشه @@ -364,10 +407,10 @@ به نظر خوبه چه خبر؟ موافقم - خیر + نه دوستت دارم متاسفم خخخخ - درست است + خوبه diff --git a/presentation/src/main/res/values-hr/strings.xml b/presentation/src/main/res/values-hr/strings.xml index ca79be70a973d677be91f0fd7b234b9e719c77f0..227c93b45b780766511e6e199380d83bfa555755 100644 --- a/presentation/src/main/res/values-hr/strings.xml +++ b/presentation/src/main/res/values-hr/strings.xml @@ -1,5 +1,4 @@ - + SIM %1$d (%2$s) selected %s selected, change SIM card Pošalji poruku Slanje… Isporučeno %s Slanje nije uspjelo. Dodirnite da biste pokušali ponovno Detalji + Address copied Conversation title Obavijesti Arhiva @@ -177,6 +185,7 @@ Potpuno crni noćni način Vrijeme početka Vrijeme završetka + Automatic contact colors Veličina fonta Koristi sustavski font Automatsk emoji @@ -187,6 +196,7 @@ Button 2 Button 3 Pregledi obavijesti + Wake screen Vibracija Zvuk None @@ -204,9 +214,10 @@ None Archive Delete + Block Call - Mark as read - Mark as unread + Mark read + Mark unread Potvrde o dostavi Budite sigurni da su se poruke uspješno poslale @@ -216,6 +227,19 @@ Uklonite naglaske sa znakova u odlaznim SMS porukama Mobile numbers only When composing a message, only show mobile numbers + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After 1 day + After %d days + After %d days + + 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 @@ -231,6 +255,7 @@ Blocking Manager QKSMS Built-in blocking functionality in QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule 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 @@ -271,6 +296,7 @@ Otključajte + za %1$s %2$s Hvala vam za podržavanje QKSMS! Sada imate pristup svim QKSMS+ značajkama + An error has occurred, please try again QKSMS+ is free for F-Droid users! If you\'d like to support development, a donation would be highly appreciated. Donirajte putem PayPala Uskoro dolazi @@ -296,7 +322,7 @@ QKSMS is under active development, and your purchase will include all future QKSMS+ features! Učitavanje… View more conversations - Mark as read + Mark read Call Delete Show more @@ -304,10 +330,12 @@ Open conversation None - Mark as read - Reply - Call + Archive Delete + Block + Call + Mark read + Reply Yes Continue @@ -346,14 +374,15 @@ No delay - Short - Medium - Long + 3 seconds + 5 seconds + 10 seconds + 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 81bb2cf83a05582bc5a8640eed109151debd7b6e..a1e790b9982bb2098628bbb2565e515f86a3a756 100644 --- a/presentation/src/main/res/values-hu/strings.xml +++ b/presentation/src/main/res/values-hu/strings.xml @@ -1,5 +1,4 @@ - + SIM %1$d (%2$s) selected %s kiválasztva, cserélj SIM kártyát Üzenet küldése Küldés… 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 Archivál @@ -168,8 +176,8 @@ Időzített üzenet Küldés most - Copy text - Delete + Szöveg másolása + Törlés Megjelenés Általános @@ -178,6 +186,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 @@ -188,6 +197,7 @@ Gomb 2 Gomb 3 Értesítés előnézet + Képernyő felébresztése Rezgés Hang Nincs @@ -205,18 +215,31 @@ Nincs Archiválás Törlés - Hívás - Megjelölés olvasottként - Megjelölés olvasatlanként + Block + Call + Mark read + Mark unread 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 + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After 1 day + After %d days + + 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 @@ -225,13 +248,14 @@ 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 Tiltáskezelő QKSMS A QKSMS beépített tiltási funkciója + Block spam messages, numbers & unknown calls with blacklist & Schedule Egy kényelmesen kezelhető helyen a hívások és üzenetek szűrése! A Community IQ™ megakadályozza a közösség által ismert spammerek üzeneteit Automatikusan szűrheti az üzeneteket a nem kívánt számok alapján a \"Should I Answer\" alkalmazás használatával Tiltott számok másolása @@ -241,11 +265,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 @@ -269,6 +293,7 @@ Minden funkció engedélyezése + adomány %1$s %2$s Köszönjük, hogy támogatot a QKSMS-t! MOst hozzáférsz minden QKSMS+ funkcióhoz + An error has occurred, please try again A QKSMS+ minden F-Droid felhasználónak ingyenes. Ha támogatni szeretnéd a fejlesztést az alábbi módon megteheted. Támogatás PayPalon keresztül Hamarosan @@ -294,7 +319,7 @@ A QKSMS folyamatos fejlesztés alatt áll és a vásárlásod a jövőbeni QKSMS+ funkciókat is tartalmazza! Betöltés… További társalgások megtekintése - Olvasottnak jelölés + Mark read Hívás Törlés Több megjelenítése @@ -302,13 +327,15 @@ Társalgás kinyitása Nincs - Megjelölés olvasottként - Válasz - Hívás - Törlés + Archive + Delete + Block + Call + Mark read + Reply Igen - Continue + Folytatás Mégsem Törlés Mentés @@ -344,18 +371,19 @@ Nincs késleltetés - Rövid - Közepes - Hosszú + 3 seconds + 5 seconds + 10 seconds + 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 f7f5de61a5a86064a1df59b1a3a5c5ec66c30c89..6ac086a877de534da0a00cc82f9b38a103587715 100644 --- a/presentation/src/main/res/values-in/strings.xml +++ b/presentation/src/main/res/values-in/strings.xml @@ -1,5 +1,4 @@ - + SIM %1$d (%2$s) dipilih %s dipilih, ganti kartu SIM Kirim pesan Mengirim… %s terkirim Gagal mengirim. Ketuk untuk mencoba lagi Detail + Alamat disalin Judul percakapan Notifikasi Arsip @@ -176,6 +184,7 @@ Mode malam hitam pekat Waktu mulai Waktu berakhir + Warna kontak otomatis Ukuran fon Gunakan fon sistem Emoji otomatis @@ -186,6 +195,7 @@ Tombol 2 Tombol 3 Pratinjau notifikasi + Layar ambien Getar Suara Tidak ada @@ -203,6 +213,7 @@ Tidak ada Arsipkan Hapus + Blokir Panggil Tandai dibaca Tandai belum dibaca @@ -210,11 +221,22 @@ 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 + Otomatis menghapus pesan lama + Pesan akan dihapus setelah rentang hari berikut + Rentang hari + Tidak pernah + Otomatis hapus pesan lama? + Jika anda lanjutkan, %1$d pesan akan dihapus sekarang + + Setelah %d hari + + 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 @@ -230,6 +252,7 @@ Pengelola Pemblokiran QKSMS Fitur pemblokiran pesan di dalam QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule Otomatis menyaring panggilan dan pesan di dalam satu tempat! Community IQ™ memungkinkan anda memblokir pesan yang tidak diinginkan dari daftar spammer Secara otomatis menyaring pesan dari nomor yang tidak diinginkan menggunakan aplikasi \"Should I Answer\" Salin nomor yang diblokir @@ -266,6 +289,7 @@ Unlock + donate for %1$s %2$s Terima kasih telah mendukung QKSMS! Anda sekarang bisa mengakses semua fitur QKSMS+ + An error has occurred, please try again QKSMS+ selalu gratis untuk pengguna F-Droid! Jika anda ingin mendukung pengembangan, jangan ragu untuk donasi. Donasi via PayPal Segera hadir @@ -299,10 +323,12 @@ Buka percakapan Tidak ada + Arsip + Hapus + Blokir + Panggil Tandai dibaca Balas - Panggil - Hapus Ya Lanjutkan @@ -339,14 +365,15 @@ Tanpa tundaan - Singkat - Sedang - Lama + 3 detik + 5 detik + 10 detik + Otomatis 100KB 200KB - 300KB (Direkomendasikan) + 300KB 600KB 1000KB 2000KB diff --git a/presentation/src/main/res/values-lt/strings.xml b/presentation/src/main/res/values-lt/strings.xml index 6044a8438c62da7cc2acf43973b8d27c6aac5993..874c2e66ae7abf094046bcb6e808b2b27b069df0 100644 --- a/presentation/src/main/res/values-lt/strings.xml +++ b/presentation/src/main/res/values-lt/strings.xml @@ -1,5 +1,4 @@ - + SIM %1$d (%2$s) selected %s selected, change SIM card Siųsti žinutę Siunčiama… Nusiųsta %s Nepavyko nusiųsti. Spausk dar kartą norint persiųsti Dėtalės + Address copied Conversation title Pranešimai Archyvuoti @@ -179,6 +187,7 @@ Visiškai juoda nakties tema Pradžios laikas Pabaigos laikas + Automatic contact colors Šrifto dydis Naudoti sistemos šriftą Automatinis emoji @@ -189,6 +198,7 @@ Button 2 Button 3 Pranešimų peržiūra + Wake screen Vibracija Garas None @@ -206,9 +216,10 @@ None Archive Delete + Block Call - Mark as read - Mark as unread + Mark read + Mark unread Žinučių pristatymo patvirtinimai Patvirtinti, kad pranešimai buvo sėkmingai išsiųsti @@ -218,6 +229,20 @@ Ištrinti jūsų SMS žinučių simbolių akcentus Mobile numbers only When composing a message, only show mobile numbers + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After 1 day + After %d days + After %d days + After %d days + + 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ę @@ -233,6 +258,7 @@ Blocking Manager QKSMS Built-in blocking functionality in QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule Automatically filter your calls and messages in one convenient place! Community IQ™ allows you to prevent unwanted messages from community known spammers Automatiškai filtruoti žinutes nuo nežinomų numerių su „Ar reikia atsakyti“ programėle Copy blocked numbers @@ -275,6 +301,7 @@ Atrakinti + aukoti %1$s %2$s Ačiū už paramą! Dabar jūs turite prieigą prie visoms QKSMS+ funkcijoms + An error has occurred, please try again QKSMS+ yra nemokama F-Droid vartotojams! Taip pat galite paremti programos kūrėją aukodami. Paaukoti per PayPal Jau greitai @@ -300,7 +327,7 @@ QKSMS is under active development, and your purchase will include all future QKSMS+ features! Kraunama… Žiūrėti daugiau pokalbių - Žymėti skaitytą + Mark read Skambinti Delete Rodyti daugiau @@ -308,10 +335,12 @@ Atidaryti pokalbį None - Mark as read - Reply - Call + Archive Delete + Block + Call + Mark read + Reply Yes Continue @@ -351,14 +380,15 @@ Be delsimo - Trumpas - Vidutinis - Ilgas + 3 seconds + 5 seconds + 10 seconds + 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 a32b612f8fd60280da0fcd689991978f8826120b..ef536f2ba406aa7c08cdf4c05f17950df41e8f23 100644 --- a/presentation/src/main/res/values-nb/strings.xml +++ b/presentation/src/main/res/values-nb/strings.xml @@ -1,5 +1,4 @@ - + SIM %1$d (%2$s) selected %s er valgt, velg annet SIM-kort Send melding Sender … Levert %s Feil ved sending. Trykk for å prøve igjen Detaljer + Address copied Tittel på samtalen Varsler Arkiv @@ -177,6 +185,7 @@ Ren svart nattmodus Starttid Sluttidspunkt + Automatic contact colors Skriftstørrelse Bruk systemskrift Automatisk emoji @@ -187,6 +196,7 @@ Knapp 2 Knapp 3 Forhåndsvisninger av varsler + Wake screen Vibrasjon Lyd Ingen @@ -204,9 +214,10 @@ Ingen Arkivér Slett - Ring - Marker som lest - Marker som ulest + Block + Call + Mark read + Mark unread Leveringsbekreftelse Bekreft at melding ble sendt @@ -216,6 +227,18 @@ Ta bort akutt-tegn fra utgående meldinger Kun mobilnummre Vis kun mobilnummre når du skriver ny melding + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After 1 day + After %d days + + 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 @@ -231,6 +254,7 @@ Blokkeringer QKSMS Innebygd blokkeringsfunksjon i QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule Filtrer anrop og meldinger automatisk. Fellesskapets intelligens™ hjelper med å forhindre uønskede henvendelser fra plageånder Filtrer meldinger fra ukjente numre automatisk med \"Skal jeg svare\"-appen Kopier blokkerte numre @@ -269,6 +293,7 @@ Aktiver + donér for %1$s %2$s Takk for at du støtter QKSMS! Du har nå tilgang til alle funksjoner i QKSMS+ + An error has occurred, please try again QKSMS+ er gratis for F-Droid-brukere. Om du ønsker å støtte utviklingen, kan du gjerne donere. Donér med PayPal Kommer snart @@ -294,7 +319,7 @@ QKSMS er i stadig utvikling og ditt kjøp inkluderer alle fremtidige QKSMS+ funksjoner! Laster inn … Last inn flere samtaler - Marker som lest + Mark read Ring Slett Vis mer @@ -302,10 +327,12 @@ Åpne samtale Ingen - Marker som lest - Svar - Ring - Slett + Archive + Delete + Block + Call + Mark read + Reply Ja Continue @@ -344,18 +371,19 @@ Ingen forsinkelse - Kort - Middels - Lang + 3 seconds + 5 seconds + 10 seconds - 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 d9cac8b991de8f6ab14014c73f6a68b4729dd7d1..364ee8e92443683e1c7b02bbe46c39837b6c24c0 100644 --- a/presentation/src/main/res/values-ne/strings.xml +++ b/presentation/src/main/res/values-ne/strings.xml @@ -1,5 +1,4 @@ - + SIM %1$d (%2$s) selected %s selected, change SIM card Send message Sending… Delivered %s Failed to send. Tap to try again Details + Address copied Conversation title Notifications Archive @@ -175,6 +183,7 @@ Pure black night mode Start time End time + Automatic contact colors Font size Use system font Automatic emoji @@ -185,6 +194,7 @@ Button 2 Button 3 Notification previews + Wake screen Vibration Sound None @@ -202,9 +212,10 @@ None Archive Delete + Block Call - Mark as read - Mark as unread + Mark read + Mark unread Delivery confirmations Confirm that messages were sent successfully @@ -214,6 +225,18 @@ Remove accents from characters in outgoing SMS messages Mobile numbers only When composing a message, only show mobile numbers + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After 1 day + After %d days + + 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 @@ -229,6 +252,7 @@ Blocking Manager QKSMS Built-in blocking functionality in QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule 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 @@ -267,6 +291,7 @@ Unlock + donate for %1$s %2$s Thank you for supporting QKSMS! You now have access to all QKSMS+ features + An error has occurred, please try again QKSMS+ is free for F-Droid users! If you\'d like to support development, a donation would be highly appreciated. Donate via PayPal Coming soon @@ -292,7 +317,7 @@ QKSMS is under active development, and your purchase will include all future QKSMS+ features! Loading… View more conversations - Mark as read + Mark read Call Delete Show more @@ -300,10 +325,12 @@ Open conversation None - Mark as read - Reply - Call + Archive Delete + Block + Call + Mark read + Reply Yes Continue @@ -341,14 +368,15 @@ No delay - Short - Medium - Long + 3 seconds + 5 seconds + 10 seconds + 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 a36693d6fe411d5824d729a2029dd999e744e750..e6021600af1ec73db910c6df3abd50d36c8be71d 100644 --- a/presentation/src/main/res/values-night/themes.xml +++ b/presentation/src/main/res/values-night/themes.xml @@ -81,6 +81,7 @@ diff --git a/presentation/src/main/res/values-pl/strings.xml b/presentation/src/main/res/values-pl/strings.xml index 7ce844bfdefe3fea435ec82659852a6b42d10438..5db1d295b731524e703dc6bdfbf65386a0801a49 100644 --- a/presentation/src/main/res/values-pl/strings.xml +++ b/presentation/src/main/res/values-pl/strings.xml @@ -1,5 +1,4 @@ - + Wybrano kartę SIM %1$d (%2$s) Wybrano %s, zmień kartę SIM Wyślij wiadomość Wysyłanie… Dostarczono %s Nie udało się wysłać wiadomości. Dotknij, aby spróbować ponownie Szczegóły + Adres został skopiowany Tytuł rozmowy Powiadomienia Dodaj do archiwum @@ -183,6 +191,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 @@ -193,6 +202,7 @@ Przycisk 2 Przycisk 3 Zawartość powiadomień + Wybudzaj ekran Wibracje Dźwięki Brak @@ -202,7 +212,7 @@ Dotknij gdziekolwiek poza okienkiem, aby zamknąć Opóźnione wysyłanie Akcje po przeciągnięciu - Ustaw akcje po przeciągnięciu dla rozmów + Ustaw akcje dla rozmów po przeciągnięciu Przeciągnięcie w prawo Przeciągnięcie w lewo ZMIEŃ @@ -210,6 +220,7 @@ Brak Archiwizuj Usuń + Zablokuj Zadzwoń Oznacz jako przeczytane Oznacz jako nieprzeczytane @@ -217,12 +228,26 @@ 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 + Automatycznie usuwaj wiadomości + Stare wiadomości będą usuwane po określonej liczbie dni + Liczba dni + Nigdy + Usunąć automatycznie stare wiadomości? + Jeśli przejdziesz dalej, %1$d wiadomość(-ci) zostanie usuniętych + + Po 1 dniu + Po %d dniach + Po %d dniach + Po %d dniach + + 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 @@ -237,10 +262,11 @@ Menadżer Blokowania QKSMS Wbudowana funkcja blokowania w QKSMS + Blokuj spam, numery i połączenia od nieznajomych za pomocą czarnej listy i harmonogramu. 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 @@ -248,19 +274,19 @@ Numer telefonu Blokuj Zablokowane wiadomości - Tutaj pojawią się twoje zablokowane wiadomości + Miejsce dla twoich zablokowanych wiadomości Blokuj Odblokuj 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 @@ -279,6 +305,7 @@ Odblokuj i wesprzyj (%1$s %2$s) Dziękujemy za wsparcie aplikacji QKSMS! Masz teraz dostęp do wszystkich funkcji QKSMS+ + Wystąpił błąd. Spróbuj ponownie. Aplikacja QKSMS+ jest bezpłatna dla użytkowników F-Droid! Jeśli chcesz wesprzeć jej rozwój, rzuć pieniążka. Wsparcie za pośrednictwem serwisu PayPal Już wkrótce @@ -312,10 +339,12 @@ Otwórz konwersację Brak + Archiwizuj + Usuń + Zablokuj + Zadzwoń Oznacz jako przeczytane Odpowiedz - Zadzwoń - Usuń Tak Kontynuuj @@ -355,18 +384,19 @@ Bez opóźnienia - Krótki - Średni - Długi + 3 sekundy + 5 sekund + 10 sekund - 100KB - 200KB - 300KB (zalecane) - 600KB - 1000KB - 2000KB - Bez kompresji + Automatyczna + 100 KB + 200 KB + 300 KB + 600 KB + 1000 KB + 2000 KB + Wyłączona OK diff --git a/presentation/src/main/res/values-pt-rBR/strings.xml b/presentation/src/main/res/values-pt-rBR/strings.xml index 9a39c5ecf5757d6437af6a250a82c7629aea6bc1..a9b3bb1f9bb0f83ca0e8676b2fd1a8130c009c94 100644 --- a/presentation/src/main/res/values-pt-rBR/strings.xml +++ b/presentation/src/main/res/values-pt-rBR/strings.xml @@ -1,5 +1,4 @@ - + SIM %1$d (%2$s) selected %s selecionado, altere o cartão SIM Enviar mensagem Enviando… Entregue %s Falha ao enviar. Toque para tentar novamente Detalhes + Address copied Título da conversa Notificações Arquivar @@ -167,8 +175,8 @@ Mensagem agendada Enviar agora - Copiar texto - Apagar + Copy text + Delete Aparência Geral @@ -177,6 +185,7 @@ Modo noturno preto puro Hora de início Hora de término + Automatic contact colors Tamanho da fonte Usar fonte do sistema Emoji automático @@ -187,6 +196,7 @@ Botão 2 Botão 3 Pré-visualização de notificações + Wake screen Vibração Som Nenhum @@ -204,18 +214,31 @@ Nenhuma Arquivar Excluir - Chamar - Marcar como lida - Marcar como não lida + Block + Call + Mark read + Mark unread Confirmações de entrega Confirme que as mensagens foram enviadas com sucesso Assinatura - Adicionar uma assinatura às mensagens + Add a signature to the end of your messages Remover acentos Remover acentos de caracteres em mensagens SMS enviadas Somente números de celular Ao escrever uma mensagem, mostrar apenas os números de celular + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After 1 day + After %d days + + 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 Comprimir anexos MMS automaticamente Sincronizar mensagens Re-sincronizar suas mensagens com o banco de dados de SMS nativo do Android @@ -224,34 +247,35 @@ Log de depuração ativado Log de depuração desativado Digite a duração (segundos) - Bloqueio - Descartar mensagens - As mensagens enviadas pelos remetentes bloqueados serão descartadas e não ocultadas - Conversas bloqueadas - Gestor de bloqueios + Blocking + Drop messages + Drop incoming messages from blocked senders instead of hiding them + Blocked conversations + Blocking Manager QKSMS - Funcionalidade de bloqueio, nativa no QKSMS - Filtrar automaticamente as chamadas e as mensagens com a aplicação Community IQ™, com a qual pode descartar as mensagens que forem enviadas pelo \'spammers\' reconhecidos na comunidade + Built-in blocking functionality in QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule + Automatically filter your calls and messages in one convenient place! Community IQ™ allows you to prevent unwanted messages from community known spammers Automaticamente filtrar mensagens não solicitadas usando o aplicativo \"Should I Answer\" - Copiar números bloqueados - Continuar para %s e substituir os números bloqueados - Números bloqueados - Os números bloqueados aparecerão aqui - Bloquear um novo número - Bloquear mensagens de - Número de telefone - Bloquear - Mensagens bloqueadas - As mensagens bloqueadas aparecerão aqui - Bloquear - Desbloquear + 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 - Continuar para %s e bloquear este número - Continuar para %s e bloquear estes números + Continue to %s and block this number + Continue to %s and block these numbers - Continuar para %s e permitir este número - Continuar para %s e permitir estes números + Continue to %s and allow this number + Continue to %s and allow these numbers Sobre Versão @@ -269,6 +293,7 @@ Desbloqueie e doar por %1$s %2$s Obrigado por apoiar o QKSMS! Agora você tem acesso a todos os recursos do QKSMS+ + An error has occurred, please try again QKSMS+ é gratuito para usuários do F-Droid! Se você gostaria de apoiar o desenvolvimento, sinta-se livre para fazer uma doação. Doar via PayPal Em Breve @@ -294,7 +319,7 @@ O QKSMS está em desenvolvimento ativo e sua compra incluirá todos os futuros recursos do QKSMS+! Carregando… Ver mais conversas - Marcar como lida + Mark read Ligar Excluir Mostrar mais @@ -302,13 +327,15 @@ Abrir conversa Nenhuma - Marcar como lida - Responder - Chamar - Excluir + Archive + Delete + Block + Call + Mark read + Reply - Sim - Continuar + Yes + Continue Cancelar Excluir Salvar @@ -326,10 +353,10 @@ Mensagem não enviada A mensagem para %s falhou ao enviar - Sistema - Desativado - Sempre - Automático + System + Disabled + Always on + Automatic Exibir nome e mensagem @@ -344,18 +371,19 @@ Sem atraso - Curto - Médio - Longo + 3 seconds + 5 seconds + 10 seconds + Automatic 100KB 200KB - 300KB (Recomendado) + 300KB 600KB 1000KB 2000KB - Não comprimir + No compression Ok diff --git a/presentation/src/main/res/values-pt/strings.xml b/presentation/src/main/res/values-pt/strings.xml index d2c98d271892d037a90c108f5290ad4598c34ac2..a0ecdfc86f152091569a931045aa6f8ebaaf94d7 100644 --- a/presentation/src/main/res/values-pt/strings.xml +++ b/presentation/src/main/res/values-pt/strings.xml @@ -1,5 +1,4 @@ - + SIM %1$d (%2$s) selecionado %s selecionado, alterar cartão SIM Enviar mensagem A enviar… %s entregue Falha ao enviar. Toque para tentar novamente Detalhes + Endereço copiado Título da conversa Notificações Arquivar @@ -179,6 +187,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 @@ -189,6 +198,7 @@ Botão 2 Botão 3 Pré-visualização de notificações + Ligar ecrã Vibração Som Nenhum @@ -206,6 +216,7 @@ Nenhuma Arquivar Apagar + Bloquear Ligar Marcar como lida Marcar como não lida @@ -218,6 +229,18 @@ Remover acentos dos caracteres nas mensagens enviadas Apenas números de telemóvel Ao escrever uma mensagem, mostrar apenas os números móveis + Apagar mensagens antigas automaticamente + As mensagens serão eliminadas após o número de dias especificado + Número de dias + Nunca + Apagar mensagens antigas automaticamente? + Se continuar, %1$d mensagens serão eliminadas + + Após 1 dia + Após %d dias + + 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 @@ -233,6 +256,7 @@ Gestor de bloqueios QKSMS Funcionalidade de bloqueio, nativa no QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule Filtrar automaticamente as chamadas e as mensagens com a aplicação Community IQ™, com a qual pode descartar as mensagens que forem enviadas pelo \'spammers\' reconhecidos na comunidade Filtrar automaticamente as mensagens de números utilizando a aplicação \"Should I Answer\" Copiar números bloqueados @@ -271,6 +295,7 @@ Desbloquear + com uma doação de %1$s %2$s Obrigado por apoiar o QKSMS! Agora, tem acesso a todas as funções do QKSMS+ + Ocorreu um erro. Por favor tente novamente. O QKSMS+ é gratuito para os utilizadores F-Droid! Se quiser ajudar no desenvolvimento, pode efetuar um donativo. Donativos por PayPal Brevemente @@ -304,10 +329,12 @@ Abrir conversa Nenhuma + Arquivar + Apagar + Bloquear + Ligar Marcar como lida Responder - Ligar - Apagar Sim Continuar @@ -345,18 +372,19 @@ Não - Curto - Médio - Longo + 3 segundos + 5 segundos + 10 segundos - 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 268fceb512af75adc83f33c9b946566500e3953c..9e98ed693d7901237daa35d65f4e373cb128a958 100644 --- a/presentation/src/main/res/values-ro/strings.xml +++ b/presentation/src/main/res/values-ro/strings.xml @@ -1,5 +1,4 @@ - + SIM %1$d (%2$s) selected %s selected, change SIM card Send message Sending… Delivered %s Failed to send. Tap to try again Details + Address copied Conversation title Notifications Archive @@ -177,6 +185,7 @@ Pure black night mode Start time End time + Automatic contact colors Font size Use system font Automatic emoji @@ -187,6 +196,7 @@ Button 2 Button 3 Notification previews + Wake screen Vibration Sound None @@ -204,9 +214,10 @@ None Archive Delete + Block Call - Mark as read - Mark as unread + Mark read + Mark unread Delivery confirmations Confirm that messages were sent successfully @@ -216,6 +227,19 @@ Remove accents from characters in outgoing SMS messages Mobile numbers only When composing a message, only show mobile numbers + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After 1 day + After %d days + After %d days + + 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 @@ -231,6 +255,7 @@ Blocking Manager QKSMS Built-in blocking functionality in QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule 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 @@ -271,6 +296,7 @@ Unlock + donate for %1$s %2$s Thank you for supporting QKSMS! You now have access to all QKSMS+ features + An error has occurred, please try again QKSMS+ is free for F-Droid users! If you\'d like to support development, a donation would be highly appreciated. Donate via PayPal Coming soon @@ -296,7 +322,7 @@ QKSMS is under active development, and your purchase will include all future QKSMS+ features! Loading… View more conversations - Mark as read + Mark read Call Delete Show more @@ -304,10 +330,12 @@ Open conversation None - Mark as read - Reply - Call + Archive Delete + Block + Call + Mark read + Reply Yes Continue @@ -346,14 +374,15 @@ No delay - Short - Medium - Long + 3 seconds + 5 seconds + 10 seconds + Automatic 100KB 200KB - 300KB (Recommended) + 300KB 600KB 1000KB 2000KB 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..6a6b4be54950e422565ef1a72f16d8526cde6b6b --- /dev/null +++ b/presentation/src/main/res/values-sl/strings.xml @@ -0,0 +1,425 @@ + + + + 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 + Mark read + Mark unread + Blokiraj + Sinhroniziranje sporočil… + Vi: %s + Osnutek + 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 + Pošlji povratne 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 + + SIM %1$d (%2$s) selected + %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 + 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 + Črn 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 + Block + Call + Mark read + Mark unread + + 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 + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After 1 day + After %d days + After %d days + After %d days + + 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 Message + 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 + Block spam messages, numbers & unknown calls with blacklist & Schedule + 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 + An error has occurred, please try again + 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 + Mark read + Pokliči + Izbriši + Pokaži več + Pokaži manj + Odpri pogovor + Material + HEX + Potrdi + + Brez + Archive + Delete + Block + Call + Mark read + Reply + + 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čen + Vedno vključen + Samodejen + + + Prikaži ime in sporočilo + Prikaži ime + Skrij vsebino + + + Majhna + Običajna + Velika + Večja + + + Brez zakasnitve + 3 seconds + 5 seconds + 10 seconds + + + Samodejno + 100 kB + 200 kB + 300 kB + 600 kB + 1000 kB + 2000 kB + Brez stiskanja + + + 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-sv/strings.xml b/presentation/src/main/res/values-sv/strings.xml index 74c7f3cbff3fbff88c4a47951be7c608545c5e02..9e6c75f2b7dcce92050403be05b21144e56b9059 100644 --- a/presentation/src/main/res/values-sv/strings.xml +++ b/presentation/src/main/res/values-sv/strings.xml @@ -1,5 +1,4 @@ - + SIM %1$d (%2$s) valdes %s valt, byt SIM-kort Skicka meddelandet Skickar… Levererade %s Misslyckades med att skicka. Knacka för att försöka igen Detaljer + Adress kopierades Titel för konversation Aviseringar Arkivera @@ -169,8 +177,8 @@ Schemalagt meddelande Skicka nu - Copy text - Delete + Kopiera text + Radera Utseende Generellt @@ -179,6 +187,7 @@ Svart nattläge Starttid Sluttid + Automatiska kontaktfärger Storlek på typsnitt Använd systemteckensnitt Automatisk uttryckssymbol @@ -189,6 +198,7 @@ Knapp 2 Knapp 3 Notifikations förhandsvisningar + Väck skärm Vibration Ljud Ingen @@ -206,18 +216,31 @@ Inget Arkivera Ta bort - Ring + Blockera + Ring upp 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 + Radera gamla meddelanden automatiskt + Meddelanden kommer att raderas efter det angivna antalet dagar + Antal dagar + Aldrig + Radera gamla meddelanden automatiskt? + Om du fortsätter raderas %1$d meddelanden direkt + + Efter 1 dag + Efter %d dagar + + Skicka långa meddelanden som MMS + Om dina längre textmeddelanden inte skickas eller skickas i fel ordning kan du skicka dem som MMS-meddelanden istället. Ytterligare avgifter kan tillkomma Komprimera MMS-bilagor automatiskt Synkronisera meddelanden Synkronisera dina meddelanden med telefonens SMS-databas @@ -226,34 +249,35 @@ Felsökningsloggning aktiverades Felsökningsloggning inaktiverad Fyll i varaktighet (sekunder) - Blocking - Drop messages - Drop incoming messages from blocked senders instead of hiding them - Blocked conversations - Blocking Manager + Blockerar + Avvisa meddelanden + Avvisa inkommande meddelanden från blockerade avsändare istället för att dölja dem + 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 + Inbyggd blockeringsfunktion i QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule + Filtrera automatiskt dina samtal och meddelanden på ett bekvämt ställe! Med Community IQ™ kan du förhindra oönskade meddelanden från gemenskapens kända spammare 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 - Block - Unblock + 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 + Blockera + Avblockera - Continue to %s and block this number - Continue to %s and block these numbers + Fortsätt till %s och blockera detta nummer + Fortsätt till %s och blockera dessa nummer - Continue to %s and allow this number - Continue to %s and allow these numbers + Fortsätt till %s och tillåt detta nummer + Fortsätt till %s och tillåt dessa nummer Om Version @@ -271,6 +295,7 @@ Lås upp + donera för %1$s %2$s Tack för att du stöttar QKSMS! Du har nu tillgång till alla funktioner i QKSMS+ + An error has occurred, please try again QKSMS + är gratis för F-Droid användare! Om du vill stödja utvecklingen, Så får du gärna donationera. Donera via PayPal Kommer snart @@ -304,13 +329,15 @@ Öppna konversation Inget + Arkivera + Radera + Blockera + Ring upp Markera som läst Svara - Ring - Ta bort - Yes - Continue + Ja + Fortsätt Avbryt Radera Spara @@ -328,9 +355,9 @@ Meddelandet till %s misslyckades att skicka System - Disabled - Always on - Automatic + Inaktiverad + Alltid på + Automatisk Visa namn och meddelande @@ -345,23 +372,24 @@ Ingen fördröjning - Kort - Mellan - Lång + 3 sekunder + 5 sekunder + 10 sekunder + Automatisk 100KB - 200KB - 300KB (Rekommenderas) - 600KB + 200kB + 300KB + 600kB 1000KB 2000KB - Ingen kompression + Ingen komprimering Okej Ett ögonblick - På väg + Jag är på väg Tack Låter bra Vad händer? diff --git a/presentation/src/main/res/values-th/strings.xml b/presentation/src/main/res/values-th/strings.xml index 143c23de158934d210e69bf40a032cca83ebcaee..547eaab961938cb24f741f68474ff8c084ac07ba 100644 --- a/presentation/src/main/res/values-th/strings.xml +++ b/presentation/src/main/res/values-th/strings.xml @@ -1,5 +1,4 @@ - + SIM %1$d (%2$s) selected %s เลือก เปลี่ยน SIM การ์ด ส่งข้อความ กำลังส่ง... %s ถูกส่งแล้ว การส่งล้มเหลว ลองแตะเพื่อส่งอีกครั้ง รายละเอียด + Address copied หัวข้อสนทนา การแจ้งเตือน จัดเก็บ @@ -173,6 +181,7 @@ โหมดกลางคืนแบบมืดสนิท เวลาเริ่มต้น เวลาสิ้นสุด + Automatic contact colors ขนาดแบบอักษร ใช้แบบอักษรของระบบ อีโมจิอัตโนมัติ @@ -183,6 +192,7 @@ Button 2 Button 3 ตัวอย่างการแจ้งเตือน + Wake screen การสั่น เสียง ไม่มี @@ -200,9 +210,10 @@ None Archive Delete + Block Call - Mark as read - Mark as unread + Mark read + Mark unread ยืนยันการส่ง ยืนยันว่า ส่งข้อความเรียบร้อยแล้ว @@ -212,6 +223,17 @@ ลบอักษรพิเศษ accents ออกจากข้อความ SMS ที่กำลังจะส่งออก Mobile numbers only When composing a message, only show mobile numbers + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After %d days + + 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 @@ -227,6 +249,7 @@ Blocking Manager QKSMS Built-in blocking functionality in QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule Automatically filter your calls and messages in one convenient place! Community IQ™ allows you to prevent unwanted messages from community known spammers กรองข้อความจากหมายเลขที่ไม่พึงประสงค์ โดยใช้แอพควรตอบกลับหรือไม่ Copy blocked numbers @@ -263,6 +286,7 @@ ปลดล็อค + บริจาค %1$s %2$s ขอบคุณสำหรับการสนับสนุน QKSMS ตอนนี้คุณสามารถเข้าถึงคุณสมบัติทั้งหมดของ QKSMS+ ได้แล้ว + An error has occurred, please try again QKSMS+ เป็นบริการฟรีสำหรับผู้ใช้งาน F-Droid หากคุณต้องการสนับสนุนการพัฒนา สามารถบริจาคได้ บริจาคผ่าน PayPal เร็วๆ นี้ @@ -288,7 +312,7 @@ QKSMS is under active development, and your purchase will include all future QKSMS+ features! กำลังโหลด… ดูการสนทนาเพิ่มเติม - ทำเครื่องหมายอ่าน + Mark read โทร Delete แสดงเพิ่มเติม @@ -296,10 +320,12 @@ เริ่มการสนทนา None - Mark as read - Reply - Call + Archive Delete + Block + Call + Mark read + Reply Yes Continue @@ -336,18 +362,19 @@ ไม่ล่าช้า - สั้น - ปานกลาง - ยาว + 3 seconds + 5 seconds + 10 seconds + Automatic 100KB 200KB - 300KB (แนะนำ) + 300KB 600KB - 1000 กิโลไบต์ - 2000 กิโลไบต์ - ไม่มีการบีบอัด + 1000KB + 2000KB + No compression ตกลง diff --git a/presentation/src/main/res/values-ur/strings.xml b/presentation/src/main/res/values-ur/strings.xml index f2af75b4be753a8f18cb6c932297e1df40c304c7..c8d9ccb045f0fe31b8eefa78903bdc4385ffe526 100644 --- a/presentation/src/main/res/values-ur/strings.xml +++ b/presentation/src/main/res/values-ur/strings.xml @@ -1,5 +1,4 @@ - + SIM %1$d (%2$s) selected %s selected, change SIM card Send message Sending… Delivered %s Failed to send. Tap to try again Details + Address copied Conversation title Notifications Archive @@ -175,6 +183,7 @@ Pure black night mode Start time End time + Automatic contact colors Font size Use system font Automatic emoji @@ -185,6 +194,7 @@ Button 2 Button 3 Notification previews + Wake screen Vibration Sound None @@ -202,9 +212,10 @@ None Archive Delete + Block Call - Mark as read - Mark as unread + Mark read + Mark unread Delivery confirmations Confirm that messages were sent successfully @@ -214,6 +225,18 @@ Remove accents from characters in outgoing SMS messages Mobile numbers only When composing a message, only show mobile numbers + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After 1 day + After %d days + + 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 @@ -229,6 +252,7 @@ Blocking Manager QKSMS Built-in blocking functionality in QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule 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 @@ -267,6 +291,7 @@ Unlock + donate for %1$s %2$s Thank you for supporting QKSMS! You now have access to all QKSMS+ features + An error has occurred, please try again QKSMS+ is free for F-Droid users! If you\'d like to support development, a donation would be highly appreciated. Donate via PayPal Coming soon @@ -292,7 +317,7 @@ QKSMS is under active development, and your purchase will include all future QKSMS+ features! Loading… View more conversations - Mark as read + Mark read Call Delete Show more @@ -300,10 +325,12 @@ Open conversation None - Mark as read - Reply - Call + Archive Delete + Block + Call + Mark read + Reply Yes Continue @@ -341,14 +368,15 @@ No delay - Short - Medium - Long + 3 seconds + 5 seconds + 10 seconds + 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-vi/strings.xml b/presentation/src/main/res/values-vi/strings.xml index d3da3aad1c8acf7fa0ae4d89635113cdf033f4df..2c9450e0950230a3c7a7e176bdaa11861af7f371 100644 --- a/presentation/src/main/res/values-vi/strings.xml +++ b/presentation/src/main/res/values-vi/strings.xml @@ -25,13 +25,16 @@ Lưu trữ Cài đặt Thông báo + Chủ đề Tìm kiếm tin nhắn… 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 @@ -42,11 +45,11 @@ Ghim lên đầu Bỏ ghim Đánh dấu đã đọc - Đánh dấu là chưa đọc + Đánh dấu chưa đọc Chặn liên hệ Đang đồng bộ tin nhắn… Bạn: %s - Bản nháp: %s + Bản nháp Kết quả trong tin nhắn %d tin nhắn Các cuộc trò chuyện của bạn sẽ xuất hiện ở đây @@ -77,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 @@ -110,12 +117,15 @@ Hẹn giờ tin nhắn Đính kèm liên hệ Lỗi đọc liên hệ + + Đã chọn SIM %1$d (%2$s) %s đã được chọn, hãy đổi thẻ SIM Gửi tin nhắn Đang gửi… Đã 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 Lưu trữ @@ -154,7 +164,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 @@ -169,10 +179,12 @@ Giao diện Chung Trả lời nhanh - Chế độ ban đêm - Chế độ ban đêm tinh khiết + Chủ đề + Giao diện tối + Giao diện tối AMOLED 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 @@ -183,6 +195,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 @@ -200,9 +213,10 @@ Không dùng Lưu trữ Xoá - Gọi điện - Đánh dấu là đã đọc - Đánh dấu là chưa đọc + Chặn + Gọi + Đánh dấu đã đọc + Đánh dấu chưa đọc Xác nhận đã gửi tin Xác nhận rằng các tin nhắn được gửi thành công @@ -212,6 +226,17 @@ 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 + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After %d days + + 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ị @@ -227,6 +252,7 @@ Quản lý danh sách chặn QKSMS Chức năng chặn tích hợp sẵn trong QKSMS + Block spam messages, numbers & unknown calls with blacklist & Schedule Tự động lọc các cuộc gọi và tin nhắn của bạn ở một nơi thuận tiện! Cộng đồng IQ™ cho phép bạn ngăn chặn các tin nhắn từ những kẻ gửi tin rác được đóng góp bởi cộng đồng Tự động lọc tin nhắn từ số điện thoại không mong muốn bằng cách sử dụng ứng dụng \"Should I Answer\" Sao chép số đã chặn @@ -263,6 +289,7 @@ Mở khoá và tài trợ với %1$s %2$s Cảm ơn bạn đã ủng hộ QKSMS! Bây giờ bạn có thể sử dụng tất cả các chức năng của QKSMS+ + An error has occurred, please try again QKSMS+ miễn phí cho người sử dụng F-Droid! Bạn có thể trả phí nếu muốn hỗ trợ phát triển. Tài trợ bằng PayPal Sắp có @@ -276,8 +303,8 @@ Tin nhắn hẹn giờ sẽ được tự động gửi vào thời gian cụ thể Trì hoãn gửi tin nhắn Chờ một vài giây trước khi gửi tin nhắn để bạn có thể hoàn tác nó - Chế độ ban đêm tự động - Kích hoạt chế độ ban đêm dựa trên thời gian trong ngày + Giao diện tối tự động + Kích hoạt giao diện tối dựa trên thời gian trong ngày Chặn nâng cao Chặn tin nhắn có chứa các từ khóa hoặc phù hợp với các mẫu Tự động chuyển tiếp @@ -294,12 +321,17 @@ Hiển thị nhiều hơn Hiển thị ít hơn Mở cuộc trò chuyện + Material + Hệ 16 + Áp dụng Không dùng - Đánh dấu là đã đọc - Trả lời - Gọi + Lưu trữ Xoá + Chặn + Gọi + Đánh dấu đã đọc + Trả lời Đồng ý Tiếp tục @@ -312,13 +344,14 @@ Undo Đã sao chép Cuộc trò chuyện đã được lưu trữ + Bạn phải mở khóa QKSMS+ để sử dụng chức năng này %s tin nhắn mới Các tin không gửi Các tin gửi đến %s không thành công - Hệ thống + Theo hệ thống Vô hiệu hoá Luôn bật Tự động @@ -336,17 +369,18 @@ Không trì hoãn - Ngắn - Vừa - Dài + 3 giây + 5 giây + 10 giây + 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 025338da1f4d84f8f5c2c3e68f0dd5e83c525ba4..b831e9e16dae042225c546a018bdea7696c54041 100644 --- a/presentation/src/main/res/values-zh-rCN/strings.xml +++ b/presentation/src/main/res/values-zh-rCN/strings.xml @@ -25,20 +25,23 @@ 已存档 设置 通知 + 主题 搜索收件箱… 撰写 跳过 继续 + 添加人物 致电 详情 保存到图库 + 分享 打开导航栏 已选择 %d 项 清除 存档 取消存档 删除 - Add to contacts + 添加到通讯录 固定到顶部 取消固定 标记为已读 @@ -46,7 +49,7 @@ 拦截 正在同步信息… 你:%s - Draft: %s + 草稿 信息中的结果 %d 条信息 对话会在这里显示 @@ -73,12 +76,16 @@ 你确定要删除%d段对话么? - 复制 + 复制文本 转发 删除 - 已选择%d个项目 - 已显示%2$d中的%1$d条结果 + 选择电话号码 + %s ∙ 默认 + 仅一次 + 始终 + 已选择 %d 个项目 + 已显示 %2$d 中的 %1$d 条结果 群发消息 收件人和回复将对所有人可见 对话从这里开始,说些什么吧! @@ -87,7 +94,7 @@ 所选时间必须是未来 已添加到预约短信队列 写一条消息... - 复制 + 复制文本 转发 删除 上一个 @@ -110,14 +117,18 @@ 定时消息 附加联系人 读取联系人时出错 - %s已选择, 更改SIM卡 + + 已选择 SIM 卡 %1$d (%2$s) + %s 已选择,更改 SIM 卡 发送消息 正在发送... 已送达 %s 发送失败。点击再试一次 详情 + 地址已复制 对话标题 通知 + 主题 存档 取消存档 拦截 @@ -130,9 +141,10 @@ 正在从备份还原 上次备份 正在加载… - 永不 + 从未 还原 选择备份 + 请解锁 Message 以使用备份和还原 正在进行备份… 正在还原… 从备份还原 @@ -144,12 +156,12 @@ %d 条消息 - 目前, 只有 SMS 支持备份和恢复。彩信支持和定时备份将很快到来! + 目前只有短信支持备份和恢复。彩信支持和定时备份将很快到来! 立即备份 正在分析备份… %d/%d 消息 正在保存备份… - 正在同步消息… + 正在同步信息… 完成! 备份和还原 预约 @@ -163,8 +175,8 @@ 预约短信 现在发送 - Copy text - Delete + 复制文本 + 删除 外观 通用 @@ -173,6 +185,7 @@ 纯黑夜间模式 开始时间 结束时间 + 自动化联系人颜色 字体大小 使用系统字体 自动颜文字 @@ -183,6 +196,7 @@ 按钮2 按钮3 通知预览 + 唤醒屏幕 振动 铃声 @@ -200,6 +214,7 @@ 归档 删除 + 拦截 呼叫 标记为已读 标记为未读 @@ -207,45 +222,57 @@ 送达确认 确认短信已成功送达 签名 - Add a signature to the end of your messages - 删除读音符号 - 删除短信中字母上的读音符号 + 在您的信息结尾添加签名 + 删除重音符号 + 删除短信中字母上的重音符号 仅限手机号码 - 撰写邮件时, 只显示手机号码 + 撰写邮件时,只显示手机号码 + 自动删除旧消息 + 消息将在指定天数后删除 + 天数 + 从不 + 自动删除旧消息吗? + 如果您继续,现在将删除 %1$d 条消息 + + %d 天后 + + 将长信息作为彩信发送 + 如果您无法发送较长的信息,或者是以错误的顺序发送,您可以将其作为彩信发送。可能会收取额外的费用 自动压缩彩信附件 同步消息 重新与安卓原生短信数据库进行同步 关于 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 - 使用\"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 + 屏蔽 + 丢弃信息 + 丢弃屏蔽的发件人所发送的信息 + 已屏蔽的会话 + 屏蔽管理器 + Message + Message 中内建的屏蔽功能 + Block spam messages, numbers & unknown calls with blacklist & Schedule + 自动在适当的位置过滤您的来电和信息!Community IQ™ 允许您阻止社区中已知的垃圾发件人所发送的垃圾信息 + 使用 \"Should I Answer\" 应用过滤未知号码的消息 + 复制屏蔽的号码 + 继续前往%s并复制到您现有的已屏蔽号码 + 已屏蔽的号码 + 您所屏蔽的号码将会在这里显示 + 屏蔽新的号码 + 屏蔽短信自 + 电话号码 + 屏蔽 + 已屏蔽的信息 + 您所屏蔽的信息将会在这里显示 + 屏蔽 + 取消屏蔽 - Continue to %s and block these numbers + 继续前往%s并屏蔽这个(些)号码 - Continue to %s and allow these numbers + 继续前往%s并允许这个(些)号码 关于 版本 @@ -256,15 +283,16 @@ 许可证 版权 支持开发,解锁全部功能 - 你可以支付%s来拯救一个饥渴的程序猿 + 你可以支付 %s 来拯救一个饥渴的程序猿 终身升级只需 %1$s %2$s 解锁并捐赠只需 %1$s %2$s - 感谢你对QKSMS的支持! - 现在你可以使用QKSMS+的全部功能! - QKSMS+对F-Droid用户免费!如果您想支持开发,可以随意捐款。 - 使用PayPal捐赠 + 感谢你对 Message 的支持! + 现在你可以使用 QKSMS+ 的全部功能! + 发生错误,请重试 + QKSMS+ 对 F-Droid 用户免费!如果您想支持开发,可以随意捐款。 + 使用 PayPal 捐赠 即将推出 高级主题 解锁 \"Material Design\" 调色板中不可用的美丽彩色主题 @@ -285,7 +313,7 @@ 自动回复 使用预设文本自动回复短信 更多 - QKSMS积极更新中,您的购买将包含以后QKSMS+的所有功能 + Message 积极更新中,您的购买将包含未来 QKSMS+ 的所有功能! 载入中... 查看更多对话 标记为已读 @@ -296,13 +324,15 @@ 打开会话 + 存档 + 删除 + 拦截 + 呼叫 标记为已读 回复 - 呼叫 - 删除 - Yes - Continue + + 继续 取消 删除 保存 @@ -318,10 +348,10 @@ 消息未发送 给%s的短信发送失败 - System - Disabled - Always on - Automatic + 系统 + 已禁用 + 始终打开 + 自动 显示姓名和消息 @@ -336,14 +366,15 @@ 无延迟 - - - + 3 秒 + 5 秒 + 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 8bf7032f4af0ac0d622e799f8bccc29fd77811e4..48b2941cda7363e02fe42d0d5fb9034e881de083 100644 --- a/presentation/src/main/res/values-zh/strings.xml +++ b/presentation/src/main/res/values-zh/strings.xml @@ -29,9 +29,11 @@ 編輯 跳过 繼續 + 增加傳送人 致電 詳細資訊 保存到圖庫 + 分享 打開導航抽屜 已選擇%d項 清除 @@ -41,12 +43,12 @@ 新增聯絡人 置頂至頂部 取消置頂 - 標記為已讀 - 標記為未讀 + 標示已讀 + 標示未讀 攔截 正在同步... 您:%s - 草稿: %s + 草稿 信息中的結果 %d 條信息 對話會在這裡顯示 @@ -77,6 +79,10 @@ 轉寄 刪除 + 選一個號碼 + %s ∙ 預設 + 就這一次 + 總是如此 已選取 %d 個項目 已顯示 %2$d 條中的 %1$d 條結果 群發訊息 @@ -111,13 +117,16 @@ 排程訊息 附加聯絡人 讀取聯絡人時出錯 + + 已選取%1$d (%2$s) SIM卡 %s 已選擇,變更 SIM 卡 發送訊息 正在發送... 已傳遞 %s 發送失敗,點擊重試 詳細資訊 - 會話標題 + 已複製位址 + 對話標題 通知 存檔 取消存檔 @@ -175,6 +184,7 @@ 純黑色夜間模式 開始時間 結束時間 + 聯絡人自動上色 字體大小 使用系統字體 自動 emoji 表情 @@ -185,6 +195,7 @@ 按鈕2 按鈕3 通知預覽 + 喚醒畫面 震動 音效 @@ -202,7 +213,8 @@ 封存 刪除 - 呼叫 + 封鎖 + 通話 標示已讀 標示未讀 @@ -214,6 +226,17 @@ 刪除訊息中字母上的讀音符號 僅限手機號碼 撰寫訊息時,只顯示手機號碼 + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + After %d days + + 以MMS傳送長訊息 + 若您較長的訊息無法傳送或是以錯誤順序傳送,您可以改用MMS方式傳送訊息。(可能會有額外費用) 自動壓縮MMS附件 同步消息 重新與安卓原生訊息數據庫進行同步 @@ -229,6 +252,7 @@ 封鎖管理器 QKSMS QKSMS內建封鎖功能 + Block spam messages, numbers & unknown calls with blacklist & Schedule 自動過濾來電與簡訊!群眾智慧Community IQ™能讓你遠離不想要的垃圾簡訊 使用\"Should I Answer\"應用過濾未知號碼的消息 複製封鎖號碼 @@ -265,6 +289,7 @@ 解鎖並捐贈只需 %1$s %2$s 感謝您對 QKSMS 的支持! 現在您可以使用 QKSMS+ 的全部功能! + An error has occurred, please try again QKSMS+對F-Droid用戶免費!如果您想支持開發,可以隨意捐款。 使用PayPal捐贈 即將推出 @@ -290,7 +315,7 @@ QKSMS 正在積極開發中,您的購買將包含後續 QKSMS+ 的所有功能! 載入中... 查看更多對話 - 標記為已讀 + 標示已讀 致電 刪除 顯示更多 @@ -298,10 +323,12 @@ 打開對話 + 封存 + 刪除 + 封鎖 + 通話 標示已讀 回覆 - 呼叫 - 刪除 確定 繼續 @@ -313,7 +340,9 @@ 設置 復原 已複製 - 存檔的會話 + 存檔的對話 + 您必須解鎖QKSMS+才能使用此功能 + %s條新訊息 @@ -338,18 +367,19 @@ 無延遲 - - - + 3 秒 + 5 秒 + 10 秒 + 自動 100KB 200KB - 300KB (推荐) + 300KB 600KB 1000KB 2000KB - 不压缩 + 無壓縮 好的 diff --git a/presentation/src/main/res/values/attrs.xml b/presentation/src/main/res/values/attrs.xml index 3d79ccfe300cf492cd54510eb8ebfab337a9b1c7..45f5c173175fccc20f30a30310ec5b6cd236c2a9 100644 --- a/presentation/src/main/res/values/attrs.xml +++ b/presentation/src/main/res/values/attrs.xml @@ -22,6 +22,7 @@ + @@ -53,7 +54,7 @@ - + @@ -62,4 +63,11 @@ - \ No newline at end of file + + + + + + + + diff --git a/presentation/src/main/res/values/colors.xml b/presentation/src/main/res/values/colors.xml index d394844d98cc982cf16ed032964d4328a53c8891..d361908c9d4e5f2a54d138dd60b25aed22db248e 100644 --- a/presentation/src/main/res/values/colors.xml +++ b/presentation/src/main/res/values/colors.xml @@ -48,6 +48,7 @@ #67ffffff @lineageos.platform:color/color_default_secondary_text + #0F1113 @color/colorPrimaryDark @color/colorPrimaryDark @@ -73,4 +74,263 @@ @lineageos.platform:color/color_default_accent @lineageos.platform:color/color_default_selector @lineageos.platform:color/color_default_foreground + + + + #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 2d0b74d2fe5251493609b7810f34f6d8bd2c6a7f..3eba897cb7dda4c212e33c809cbbd1876795a0c9 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -27,15 +27,18 @@ Archived Settings Notifications + Theme Search inbox… Type a name or number Skip Continue + Add person Call Details Save to gallery + Share Open navigation drawer %d selected @@ -46,12 +49,12 @@ Add to contacts Pin to top Unpin - Mark as read - Mark as unread + Mark read + Mark unread Block Syncing messages… You: %s - Draft: %s + Draft Results in messages %d messages Your conversations will appear here @@ -88,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 @@ -96,6 +103,7 @@ Contact card Scheduled for Selected time must be in the future! + You must unlock QKSMS+ to use scheduled messaging Added to scheduled messages Write a message… Copy text @@ -121,6 +129,8 @@ Schedule message Attach a contact Error reading contact + + SIM %1$d (%2$s) selected %s selected, change SIM card Send message @@ -129,8 +139,10 @@ Failed to send. Tap to try again Details + Address copied Conversation title Notifications + Theme Archive Unarchive Block @@ -148,6 +160,7 @@ Never Restore Select a backup + Please unlock QKSMS+ to use backup and restore Backup in progress… Restore in progress… Restore from backup @@ -190,10 +203,12 @@ General QK Reply + Theme Night mode Pure black night mode Start time End time + Automatic contact colors Font size Use system font Automatic emoji @@ -204,6 +219,7 @@ Button 2 Button 3 Notification previews + Wake screen Vibration Sound None @@ -221,9 +237,10 @@ None Archive Delete + Block Call - Mark as read - Mark as unread + Mark read + Mark unread Delivery confirmations Confirm that messages were sent successfully @@ -233,6 +250,19 @@ Remove accents from characters in outgoing SMS messages Mobile numbers only When composing a message, only show mobile numbers + Delete old messages automatically + Messages will be deleted after the specified number of days + Number of days + Never + Delete old messages automatically? + If you proceed, %1$d messages will be deleted now + + Never + After 1 day + After %d days + + 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 @@ -251,6 +281,8 @@ Blocking Manager QKSMS Built-in blocking functionality in QKSMS + Call Blocker - Incoming/Outgoing + Block spam messages, numbers & unknown calls with blacklist & Schedule Call Control Automatically filter your calls and messages in one convenient place! Community IQ™ allows you to prevent unwanted messages from community known spammers Should I Answer? @@ -285,6 +317,13 @@ Source code License Copyright + + Developer + Changelog + Contact + + + Message Information App Version - Message is an open source replacement to the stock messaging app on Android. @@ -303,6 +342,7 @@ Unlock + donate for %1$s %2$s Thank you for supporting QKSMS! You now have access to all QKSMS+ features + An error has occurred, please try again QKSMS+ is free for F-Droid users! If you\'d like to support development, a donation would be highly appreciated. Donate via PayPal Coming soon @@ -330,13 +370,19 @@ Loading… View more conversations - Mark as read + Mark read Call Delete Show more Show less Open conversation + Material + QKSMS+ + HEX + Apply + + Moez Bhatti https://github.com/moezbhatti/qksms https://github.com/moezbhatti/qksms/releases @@ -355,10 +401,12 @@ You must unlock QKSMS+ to use this None - Mark as read - Reply - Call + Archive Delete + Block + Call + Mark read + Reply Yes @@ -403,15 +451,16 @@ No delay - Short - Medium - Long + 3 seconds + 5 seconds + 10 seconds + Automatic 100KB 200KB - 300KB (Recommended) + 300KB 600KB 1000KB 2000KB @@ -419,6 +468,7 @@ + -1 100 200 300 @@ -445,4 +495,11 @@ Authors + + QKSMS+ + Unlock amazing new features, and support development + Enjoying Message? + Share some love and rate us on Google Play! + OKAY! + DISMISS diff --git a/presentation/src/main/res/values/themes.xml b/presentation/src/main/res/values/themes.xml index 4ed6f90af733214f0aba6d8e51382f3b6bed03ca..c09f13a5113bdb4e5d73dc7eb8bdf9c43aab20a1 100644 --- a/presentation/src/main/res/values/themes.xml +++ b/presentation/src/main/res/values/themes.xml @@ -92,8 +92,7 @@