Move packages to com.moez.QKSMS

This commit is contained in:
Moez Bhatti
2018-06-20 14:29:07 -04:00
parent d8141737b3
commit a5f518042e
268 changed files with 1101 additions and 1116 deletions
@@ -0,0 +1,34 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.compat
import android.annotation.TargetApi
import android.os.Build
import android.telephony.SubscriptionInfo
@TargetApi(Build.VERSION_CODES.LOLLIPOP_MR1)
data class SubscriptionInfoCompat(private val subscriptionInfo: SubscriptionInfo) {
val subscriptionId get() = subscriptionInfo.subscriptionId
val simSlotIndex get() = subscriptionInfo.simSlotIndex
val displayName get() = subscriptionInfo.displayName
}
@@ -0,0 +1,69 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.compat
import android.content.Context
import android.os.Build
import android.telephony.SubscriptionManager
import com.moez.QKSMS.manager.PermissionManager
import javax.inject.Inject
class SubscriptionManagerCompat @Inject constructor(context: Context, private val permissions: PermissionManager) {
private val subscriptionManager: SubscriptionManager?
get() = field?.takeIf { permissions.hasPhone() }
val activeSubscriptionInfoList: List<SubscriptionInfoCompat>
get() {
return if (Build.VERSION.SDK_INT >= 22) {
subscriptionManager?.activeSubscriptionInfoList?.map { SubscriptionInfoCompat(it) } ?: listOf()
} else listOf()
}
init {
subscriptionManager = if (Build.VERSION.SDK_INT >= 22) SubscriptionManager.from(context) else null
}
fun addOnSubscriptionsChangedListener(listener: OnSubscriptionsChangedListener) {
if (Build.VERSION.SDK_INT >= 22) {
subscriptionManager?.addOnSubscriptionsChangedListener(listener.listener)
}
}
fun removeOnSubscriptionsChangedListener(listener: OnSubscriptionsChangedListener) {
if (Build.VERSION.SDK_INT >= 22) {
subscriptionManager?.removeOnSubscriptionsChangedListener(listener.listener)
}
}
abstract class OnSubscriptionsChangedListener {
val listener: SubscriptionManager.OnSubscriptionsChangedListener? = if (Build.VERSION.SDK_INT >= 22) {
object : SubscriptionManager.OnSubscriptionsChangedListener() {
override fun onSubscriptionsChanged() {
this@OnSubscriptionsChangedListener.onSubscriptionsChanged()
}
}
} else null
abstract fun onSubscriptionsChanged()
}
}
@@ -0,0 +1,85 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.compat
import android.content.Context
import android.database.sqlite.SqliteWrapper
import android.net.Uri
import android.os.Build
import android.provider.BaseColumns
import android.provider.Telephony
import android.text.TextUtils
import android.util.Patterns
import timber.log.Timber
import java.util.regex.Pattern
object TelephonyCompat {
private val ID_PROJECTION = arrayOf(BaseColumns._ID)
private val THREAD_ID_CONTENT_URI = Uri.parse("content://mms-sms/threadID")
val NAME_ADDR_EMAIL_PATTERN = Pattern.compile("\\s*(\"[^\"]*\"|[^<>\"]+)\\s*<([^<>]+)>\\s*")
fun getOrCreateThreadId(context: Context, recipient: String): Long {
return getOrCreateThreadId(context, listOf(recipient))
}
fun getOrCreateThreadId(context: Context, recipients: Collection<String>): Long {
return if (Build.VERSION.SDK_INT >= 23) {
Telephony.Threads.getOrCreateThreadId(context, recipients.toSet())
} else {
val uriBuilder = THREAD_ID_CONTENT_URI.buildUpon()
recipients
.map { recipient -> if (isEmailAddress(recipient)) extractAddrSpec(recipient) else recipient }
.forEach { recipient -> uriBuilder.appendQueryParameter("recipient", recipient) }
val uri = uriBuilder.build()
SqliteWrapper.query(context, context.contentResolver, uri, ID_PROJECTION, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
return cursor.getLong(0)
} else {
Timber.e("getOrCreateThreadId returned no rows!")
}
}
Timber.e("getOrCreateThreadId failed with " + recipients.size + " recipients")
throw IllegalArgumentException("Unable to find or allocate a thread ID.")
}
}
fun extractAddrSpec(address: String): String {
val match = NAME_ADDR_EMAIL_PATTERN.matcher(address)
return if (match.matches()) {
match.group(2)
} else address
}
fun isEmailAddress(address: String): Boolean {
if (TextUtils.isEmpty(address)) {
return false
}
val s = extractAddrSpec(address)
val match = Patterns.EMAIL_ADDRESS.matcher(s)
return match.matches()
}
}
@@ -0,0 +1,89 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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)
while (moveToNext()) {
method.invoke(this)
}
if (closeOnComplete) {
close()
}
}
fun <T> Cursor.map(map: (Cursor) -> T): List<T> {
return List(count, { position ->
moveToPosition(position)
map(this)
})
}
fun <T> Cursor.mapWhile(map: (Cursor) -> T, predicate: (T) -> Boolean): ArrayList<T> {
val result = ArrayList<T>()
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
* use a loop and call onNext() from a generator because we'll need to close
* the cursor at the end, and if any items are still in the buffer, then
* they will be made invalid
*/
fun Cursor.asFlowable(): Flowable<Cursor> {
return Flowable.range(0, count)
.map {
moveToPosition(it)
this
}
.doOnComplete { close() }
}
fun Cursor.asMaybe(): Maybe<Cursor> {
val subject = MaybeSubject.create<Cursor>()
if (moveToFirst()) {
subject.onSuccess(this)
} else {
subject.onError(IndexOutOfBoundsException("The cursor has no items"))
}
subject.doOnComplete { close() }
return subject
}
@@ -0,0 +1,32 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.extensions
import com.google.android.mms.ContentType
import com.moez.QKSMS.model.MmsPart
fun MmsPart.isSmil() = ContentType.APP_SMIL == type
fun MmsPart.isImage() = ContentType.isImageType(type)
fun MmsPart.isVideo() = ContentType.isVideoType(type)
fun MmsPart.isText() = ContentType.isTextType(type)
fun MmsPart.isVCard() = ContentType.TEXT_VCARD == type
@@ -0,0 +1,56 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.extensions
import io.reactivex.Observable
import io.realm.Realm
import io.realm.RealmModel
import io.realm.RealmObject
import io.realm.RealmQuery
import io.realm.RealmResults
import timber.log.Timber
fun RealmModel.insertOrUpdate() {
val realm = Realm.getDefaultInstance()
realm.executeTransaction { realm.insertOrUpdate(this) }
realm.close()
}
fun <T : RealmModel> Collection<T>.insertOrUpdate() {
val realm = Realm.getDefaultInstance()
realm.executeTransaction { realm.insertOrUpdate(this) }
realm.close()
}
fun <T : RealmObject> T.asObservable(): Observable<T> {
return asFlowable<T>().toObservable()
}
fun <T : RealmObject> RealmResults<T>.asObservable(): Observable<RealmResults<T>> {
return asFlowable().toObservable()
}
fun <T : RealmObject> RealmQuery<T>.anyOf(fieldName: String, values: LongArray): RealmQuery<T> {
values.forEach { Timber.v("vararg: $it") }
return when (values.isEmpty()) {
true -> equalTo(fieldName, -1L)
false -> `in`(fieldName, values.toTypedArray())
}
}
@@ -0,0 +1,27 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.extensions
import java.text.Normalizer
/**
* Strip the accents from a string
*/
fun CharSequence.removeAccents() = Normalizer.normalize(this, Normalizer.Form.NFD)
.replace(Regex("[^\\p{ASCII}]"), "")
@@ -0,0 +1,32 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.filter
import com.moez.QKSMS.extensions.removeAccents
import com.moez.QKSMS.model.Contact
import javax.inject.Inject
class ContactFilter @Inject constructor(private val phoneNumberFilter: PhoneNumberFilter) : Filter<Contact>() {
override fun filter(item: Contact, query: CharSequence): Boolean {
return item.name.removeAccents().contains(query, true) || // Name
item.numbers.map { it.address }.any { address -> phoneNumberFilter.filter(address, query) } // Number
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.filter
import com.moez.QKSMS.model.Conversation
import javax.inject.Inject
class ConversationFilter @Inject constructor(private val recipientFilter: RecipientFilter) : Filter<Conversation>() {
override fun filter(item: Conversation, query: CharSequence): Boolean {
return item.recipients.any { recipient -> recipientFilter.filter(recipient, query) }
}
}
@@ -0,0 +1,25 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.filter
abstract class Filter<in T> {
abstract fun filter(item: T, query: CharSequence): Boolean
}
@@ -0,0 +1,33 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.filter
import android.telephony.PhoneNumberUtils
import javax.inject.Inject
class PhoneNumberFilter @Inject constructor() : Filter<String>() {
override fun filter(item: String, query: CharSequence): Boolean {
val allCharactersDialable = query.all { PhoneNumberUtils.isReallyDialable(it) }
return allCharactersDialable && (PhoneNumberUtils.compare(item, query.toString()) ||
PhoneNumberUtils.stripSeparators(item).contains(PhoneNumberUtils.stripSeparators(query.toString())))
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.filter
import com.moez.QKSMS.model.Recipient
import javax.inject.Inject
class RecipientFilter @Inject constructor(
private val contactFilter: ContactFilter,
private val phoneNumberFilter: PhoneNumberFilter)
: Filter<Recipient>() {
override fun filter(item: Recipient, query: CharSequence) = when {
item.contact?.let { contactFilter.filter(it, query) } == true -> true
phoneNumberFilter.filter(item.address, query) -> true
else -> false
}
}
@@ -0,0 +1,130 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.manager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Handler
import android.os.IBinder
import android.os.Message
import android.os.Messenger
import androidx.os.bundleOf
import com.moez.QKSMS.util.Preferences
import com.moez.QKSMS.util.tryOrNull
import io.reactivex.Single
import io.reactivex.subjects.SingleSubject
import timber.log.Timber
import javax.inject.Inject
class ExternalBlockingManagerImpl @Inject constructor(
private val context: Context,
private val prefs: Preferences
) : ExternalBlockingManager {
companion object {
const val RATING_UNKNOWN = 0
const val RATING_POSITIVE = 1
const val RATING_NEGATIVE = 2
const val RATING_NEUTRAL = 3
const val GET_NUMBER_RATING = 1
}
/**
* Return a Single<Boolean> which emits whether or not the given [address] should be blocked
*/
override fun shouldBlock(address: String): Single<Boolean> {
return Binder(context, prefs, address).shouldBlock()
}
private class Binder(
private val context: Context,
private val prefs: Preferences,
private val address: String
) : ServiceConnection {
private val subject: SingleSubject<Boolean> = SingleSubject.create()
private var serviceMessenger: Messenger? = null
private var isBound: Boolean = false
fun shouldBlock(): Single<Boolean> {
var intent: Intent? = null
// If either version of Should I Answer? is installed and SIA is enabled, build the
// intent to request a rating
if (prefs.sia.get()) {
intent = tryOrNull {
context.packageManager.getApplicationInfo("org.mistergroup.shouldianswerpersonal", 0).enabled
Intent("org.mistergroup.shouldianswerpersonal.PublicService").setPackage("org.mistergroup.shouldianswerpersonal")
} ?: tryOrNull {
context.packageManager.getApplicationInfo("org.mistergroup.muzutozvednout", 0).enabled
Intent("org.mistergroup.muzutozvednout.PublicService").setPackage("org.mistergroup.muzutozvednout")
}
}
// If the intent isn't null, bind the service and wait for a result. Otherwise, don't block
if (intent != null) {
context.bindService(intent, this, Context.BIND_AUTO_CREATE)
} else {
subject.onSuccess(false)
}
return subject
}
override fun onServiceConnected(className: ComponentName, service: IBinder) {
serviceMessenger = Messenger(service)
isBound = true
val msg = Message()
msg.what = GET_NUMBER_RATING
msg.data = bundleOf(Pair("number", address))
msg.replyTo = Messenger(IncomingHandler { rating ->
subject.onSuccess(rating == RATING_NEGATIVE)
Timber.v("Should block: ${rating == RATING_NEGATIVE}")
// We're done, so unbind the service
if (isBound && serviceMessenger != null) {
context.unbindService(this)
}
})
serviceMessenger?.send(msg)
}
override fun onServiceDisconnected(className: ComponentName) {
serviceMessenger = null
isBound = false
}
}
private class IncomingHandler(private val callback: (rating: Int) -> Unit) : Handler() {
override fun handleMessage(msg: Message) {
when (msg.what) {
GET_NUMBER_RATING -> callback(msg.data.getInt("rating"))
else -> super.handleMessage(msg)
}
}
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.manager
import com.moez.QKSMS.model.Message
import io.realm.Realm
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class KeyManagerImpl @Inject constructor() : KeyManager {
private var initialized = false
private var maxValue: Long = 0
/**
* Should be called when a new sync is being started
*/
override fun reset() {
initialized = true
maxValue = 0L
}
/**
* Returns a valid ID that can be used to store a new message
*/
override fun newId(): Long {
if (!initialized) {
maxValue = Realm.getDefaultInstance().use { realm ->
realm.where(Message::class.java).max("id")?.toLong() ?: 0L
}
initialized = true
}
maxValue++
return maxValue
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.manager
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.provider.Telephony
import android.support.v4.content.ContextCompat
import javax.inject.Inject
class PermissionManagerImpl @Inject constructor(private val context: Context) : PermissionManager {
override fun isDefaultSms(): Boolean {
return Telephony.Sms.getDefaultSmsPackage(context) == context.packageName
}
override fun hasSmsAndContacts(): Boolean {
return hasSms() && hasContacts()
}
override fun hasSms(): Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_SMS) == PERMISSION_GRANTED
override fun hasContacts(): Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PERMISSION_GRANTED
override fun hasPhone(): Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PERMISSION_GRANTED
override fun hasStorage(): Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PERMISSION_GRANTED
}
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.manager
import android.content.Context
import android.content.Intent
import com.klinker.android.send_message.BroadcastUtils
import me.leolin.shortcutbadger.ShortcutBadger
import javax.inject.Inject
class WidgetManagerImpl @Inject constructor(private val context: Context) : WidgetManager {
override fun updateUnreadCount(count: Int) {
ShortcutBadger.applyCount(context, count)
BroadcastUtils.sendExplicitBroadcast(context, Intent(), WidgetManager.ACTION_NOTIFY_DATASET_CHANGED)
}
}
@@ -0,0 +1,61 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.mapper
import android.content.Context
import android.database.Cursor
import android.provider.ContactsContract.CommonDataKinds.Phone.*
import com.moez.QKSMS.manager.PermissionManager
import com.moez.QKSMS.model.Contact
import com.moez.QKSMS.model.PhoneNumber
import javax.inject.Inject
class CursorToContactImpl @Inject constructor(
private val context: Context,
private val permissionManager: PermissionManager
) : CursorToContact {
companion object {
val URI = CONTENT_URI
val PROJECTION = arrayOf(LOOKUP_KEY, NUMBER, TYPE, DISPLAY_NAME)
const val COLUMN_LOOKUP_KEY = 0
const val COLUMN_NUMBER = 1
const val COLUMN_TYPE = 2
const val COLUMN_DISPLAY_NAME = 3
}
override fun map(from: Cursor) = Contact().apply {
lookupKey = from.getString(COLUMN_LOOKUP_KEY)
name = from.getString(COLUMN_DISPLAY_NAME) ?: ""
numbers.add(PhoneNumber().apply {
address = from.getString(COLUMN_NUMBER) ?: ""
type = context.getString(getTypeLabelResource(from.getInt(COLUMN_TYPE)))
})
lastUpdate = System.currentTimeMillis()
}
override fun getContactsCursor(): Cursor? {
return when (permissionManager.hasContacts()) {
true -> context.contentResolver.query(URI, PROJECTION, null, null, null)
false -> null
}
}
}
@@ -0,0 +1,83 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.mapper
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.Telephony.Threads
import com.moez.QKSMS.manager.PermissionManager
import com.moez.QKSMS.model.Conversation
import com.moez.QKSMS.model.Recipient
import javax.inject.Inject
class CursorToConversationImpl @Inject constructor(
private val context: Context,
private val permissionManager: PermissionManager
) : CursorToConversation {
companion object {
val URI: Uri = Uri.parse("content://mms-sms/conversations?simple=true")
val PROJECTION = arrayOf(
Threads._ID,
Threads.DATE,
Threads.RECIPIENT_IDS,
Threads.MESSAGE_COUNT,
Threads.READ,
Threads.SNIPPET)
const val ID = 0
const val DATE = 1
const val RECIPIENT_IDS = 2
const val MESSAGE_COUNT = 3
const val READ = 4
const val SNIPPET = 5
}
override fun map(from: Cursor): Conversation {
return Conversation().apply {
id = from.getLong(ID)
date = from.getLong(DATE)
recipients.addAll(from.getString(RECIPIENT_IDS)
.split(" ")
.filter { it.isNotBlank() }
.map { recipientId -> recipientId.toLong() }
.map { recipientId -> Recipient().apply { id = recipientId } })
count = from.getInt(MESSAGE_COUNT)
read = from.getInt(READ) == 1
snippet = from.getString(SNIPPET) ?: ""
}
}
override fun getConversationsCursor(lastSync: Long): Cursor? {
return when (permissionManager.hasSms()) {
true -> context.contentResolver.query(URI, PROJECTION,
"date > $lastSync", null,
"date desc")
false -> null
}
}
override fun getConversationCursor(threadId: Long): Cursor? {
return context.contentResolver.query(URI, PROJECTION,
"_id = ?", arrayOf(threadId.toString()), null)
}
}
@@ -0,0 +1,156 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.mapper
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.Telephony.*
import com.google.android.mms.pdu_alt.PduHeaders
import com.moez.QKSMS.extensions.map
import com.moez.QKSMS.manager.KeyManager
import com.moez.QKSMS.manager.PermissionManager
import com.moez.QKSMS.model.Message
import javax.inject.Inject
class CursorToMessageImpl @Inject constructor(
private val context: Context,
private val cursorToPart: CursorToPart,
private val keys: KeyManager,
private val permissionManager: PermissionManager
) : CursorToMessage {
companion object {
val URI = Uri.parse("content://mms-sms/complete-conversations")
val PROJECTION = arrayOf(
MmsSms.TYPE_DISCRIMINATOR_COLUMN,
MmsSms._ID,
Mms.DATE,
Mms.DATE_SENT,
Mms.READ,
Mms.THREAD_ID,
Mms.LOCKED,
Mms.SUBSCRIPTION_ID,
Sms.ADDRESS,
Sms.BODY,
Sms.SEEN,
Sms.TYPE,
Sms.STATUS,
Sms.ERROR_CODE,
Mms.SUBJECT,
Mms.SUBJECT_CHARSET,
Mms.SEEN,
Mms.MESSAGE_TYPE,
Mms.MESSAGE_BOX,
Mms.DELIVERY_REPORT,
Mms.READ_REPORT,
MmsSms.PendingMessages.ERROR_TYPE,
Mms.STATUS)
}
override fun map(from: Pair<Cursor, CursorToMessage.MessageColumns>): Message {
val cursor = from.first
val columnsMap = from.second
return Message().apply {
type = when {
cursor.getColumnIndex(MmsSms.TYPE_DISCRIMINATOR_COLUMN) != -1 -> cursor.getString(columnsMap.msgType)
cursor.getColumnIndex(Mms.SUBJECT) != -1 -> "mms"
cursor.getColumnIndex(Sms.ADDRESS) != -1 -> "sms"
else -> "unknown"
}
id = keys.newId()
threadId = cursor.getLong(columnsMap.threadId)
contentId = cursor.getLong(columnsMap.msgId)
date = cursor.getLong(columnsMap.date)
dateSent = cursor.getLong(columnsMap.dateSent)
read = cursor.getInt(columnsMap.read) != 0
locked = cursor.getInt(columnsMap.locked) != 0
subId = if (columnsMap.subId != -1) cursor.getInt(columnsMap.subId) else -1
when (type) {
"sms" -> {
address = cursor.getString(columnsMap.smsAddress) ?: ""
boxId = cursor.getInt(columnsMap.smsType)
seen = cursor.getInt(columnsMap.smsSeen) != 0
body = columnsMap.smsBody
.takeIf { column -> column != -1 } // The column may not be set
?.let { column -> cursor.getString(column) } ?: "" // cursor.getString() may return null
errorCode = cursor.getInt(columnsMap.smsErrorCode)
deliveryStatus = cursor.getInt(columnsMap.smsStatus)
}
"mms" -> {
address = getMmsAddress(contentId)
boxId = cursor.getInt(columnsMap.mmsMessageBox)
date *= 1000L
dateSent *= 1000L
seen = cursor.getInt(columnsMap.mmsSeen) != 0
mmsDeliveryStatusString = cursor.getString(columnsMap.mmsDeliveryReport) ?: ""
errorType = if (columnsMap.mmsErrorType != -1) cursor.getInt(columnsMap.mmsErrorType) else 0
messageSize = 0
readReportString = cursor.getString(columnsMap.mmsReadReport) ?: ""
messageType = cursor.getInt(columnsMap.mmsMessageType)
mmsStatus = cursor.getInt(columnsMap.mmsStatus)
subject = cursor.getString(columnsMap.mmsSubject) ?: ""
textContentType = ""
attachmentType = Message.AttachmentType.NOT_LOADED
parts.addAll(cursorToPart.getPartsCursor(contentId)?.map { cursorToPart.map(it) } ?: listOf())
}
}
}
}
override fun getMessagesCursor(): Cursor? {
return when (permissionManager.hasSms()) {
true -> context.contentResolver.query(URI, PROJECTION, null, null, "normalized_date desc")
false -> null
}
}
override fun getMessageCursor(id: Long): Cursor? {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}
private fun getMmsAddress(messageId: Long): String {
val uri = Mms.CONTENT_URI.buildUpon()
.appendPath(messageId.toString())
.appendPath("addr").build()
//TODO: Use Charset to ensure address is decoded correctly
val projection = arrayOf(Mms.Addr.ADDRESS, Mms.Addr.CHARSET)
val selection = "${Mms.Addr.TYPE} = ${PduHeaders.FROM}"
val cursor = context.contentResolver.query(uri, projection, selection, null, null)
cursor?.use {
if (cursor.moveToFirst()) {
return cursor.getString(0) ?: ""
}
}
return ""
}
}
@@ -0,0 +1,83 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.mapper
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.Telephony
import com.moez.QKSMS.extensions.isImage
import com.moez.QKSMS.extensions.isSmil
import com.moez.QKSMS.extensions.isText
import com.moez.QKSMS.extensions.isVideo
import com.moez.QKSMS.model.MmsPart
import timber.log.Timber
import java.io.BufferedReader
import java.io.InputStreamReader
import javax.inject.Inject
class CursorToPartImpl @Inject constructor(private val context: Context) : CursorToPart {
companion object {
val CONTENT_URI: Uri = Uri.parse("content://mms/part")
}
override fun map(from: Cursor) = MmsPart().apply {
id = from.getLong(from.getColumnIndexOrThrow(Telephony.Mms.Part._ID))
// Type will sometimes return null, resulting in a crash if we don't default to an empty string
type = from.getString(from.getColumnIndexOrThrow(Telephony.Mms.Part.CONTENT_TYPE)) ?: ""
val data = from.getString(from.getColumnIndexOrThrow(Telephony.Mms.Part._DATA))
when {
isSmil() || isImage() || isVideo() -> {
// Do nothing special
}
isText() -> {
text = if (data == null) {
from.getString(from.getColumnIndexOrThrow("text"))
} else {
val uri = ContentUris.withAppendedId(CONTENT_URI, id)
val sb = StringBuilder()
context.contentResolver.openInputStream(uri)?.use { inputStream ->
val isr = InputStreamReader(inputStream, "UTF-8")
val reader = BufferedReader(isr)
var temp = reader.readLine()
while (temp != null) {
sb.append(temp)
temp = reader.readLine()
}
}
sb.toString()
}
}
else -> Timber.v("Unhandled type: $type")
}
}
override fun getPartsCursor(messageId: Long): Cursor? {
return context.contentResolver.query(CONTENT_URI, null,
"${Telephony.Mms.Part.MSG_ID} = ?", arrayOf(messageId.toString()), null)
}
}
@@ -0,0 +1,57 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.mapper
import android.content.Context
import android.database.Cursor
import android.net.Uri
import com.moez.QKSMS.manager.PermissionManager
import com.moez.QKSMS.model.Recipient
import javax.inject.Inject
class CursorToRecipientImpl @Inject constructor(
private val context: Context,
private val permissionManager: PermissionManager
) : CursorToRecipient {
companion object {
val URI = Uri.parse("content://mms-sms/canonical-addresses")
const val COLUMN_ID = 0
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 getRecipientCursor(): Cursor? {
return when (permissionManager.hasSms()) {
true -> context.contentResolver.query(URI, null, null, null, null)
false -> null
}
}
override fun getRecipientCursor(id: Long): Cursor? {
return context.contentResolver.query(URI, null, "_id = ?", arrayOf(id.toString()), null)
}
}
@@ -0,0 +1,62 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
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 SESSION_THRESHOLD = 100
}
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 > SESSION_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)
}
}
@@ -0,0 +1,61 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.migration
import io.realm.DynamicRealm
import io.realm.FieldAttribute
import io.realm.RealmMigration
class QkRealmMigration : RealmMigration {
companion object {
const val SCHEMA_VERSION: Long = 3
}
override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) {
var version = oldVersion
if (version == 0L) {
realm.schema.get("MmsPart")
?.removeField("image")
version++
}
if (version == 1L) {
realm.schema.get("Message")
?.addField("subId", Int::class.java)
version++
}
if (version == 2L) {
realm.schema.get("Conversation")
?.addField("name", String::class.java, FieldAttribute.REQUIRED)
version++
}
if (version < newVersion) {
throw IllegalStateException("Migration missing from v$oldVersion to v$newVersion")
}
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Telephony
import android.support.annotation.RequiresApi
import com.moez.QKSMS.interactor.SyncMessages
import com.moez.QKSMS.util.Preferences
import dagger.android.AndroidInjection
import javax.inject.Inject
class DefaultSmsChangedReceiver : BroadcastReceiver() {
@Inject lateinit var prefs: Preferences
@Inject lateinit var syncMessages: SyncMessages
@RequiresApi(Build.VERSION_CODES.N)
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
if (intent.getBooleanExtra(Telephony.Sms.Intents.EXTRA_IS_DEFAULT_SMS_APP, false)) {
val pendingResult = goAsync()
syncMessages.execute(Unit, { pendingResult.finish() })
}
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.moez.QKSMS.interactor.MarkRead
import dagger.android.AndroidInjection
import javax.inject.Inject
class MarkReadReceiver : BroadcastReceiver() {
@Inject lateinit var markRead: MarkRead
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
val threadId = intent.getLongExtra("threadId", 0)
markRead.execute(listOf(threadId))
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.moez.QKSMS.interactor.MarkSeen
import dagger.android.AndroidInjection
import javax.inject.Inject
class MarkSeenReceiver : BroadcastReceiver() {
@Inject lateinit var markSeen: MarkSeen
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
val threadId = intent.getLongExtra("threadId", 0)
markSeen.execute(threadId)
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.klinker.android.send_message.MmsReceivedReceiver
import com.moez.QKSMS.interactor.ReceiveMms
import dagger.android.AndroidInjection
import javax.inject.Inject
class MmsReceivedReceiver : MmsReceivedReceiver() {
@Inject lateinit var receiveMms: ReceiveMms
override fun onReceive(context: Context?, intent: Intent?) {
AndroidInjection.inject(this, context)
super.onReceive(context, intent)
}
override fun onMessageReceived(messageUri: Uri?) {
messageUri?.let { uri ->
val pendingResult = goAsync()
receiveMms.execute(uri) { pendingResult.finish() }
}
}
}
@@ -0,0 +1,23 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import com.android.mms.transaction.PushReceiver
class MmsReceiver : PushReceiver()
@@ -0,0 +1,91 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Telephony
import com.google.android.mms.MmsException
import com.google.android.mms.util_alt.SqliteWrapper
import com.klinker.android.send_message.Transaction
import com.moez.QKSMS.interactor.SyncMessage
import dagger.android.AndroidInjection
import timber.log.Timber
import java.io.File
import javax.inject.Inject
class MmsSentReceiver : BroadcastReceiver() {
@Inject lateinit var syncMessage: SyncMessage
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
Timber.v("MMS sending result: $resultCode")
val uri = Uri.parse(intent.getStringExtra(Transaction.EXTRA_CONTENT_URI))
Timber.v(uri.toString())
when (resultCode) {
Activity.RESULT_OK -> {
Timber.v("MMS has finished sending, marking it as so in the database")
val values = ContentValues(1)
values.put(Telephony.Mms.MESSAGE_BOX, Telephony.Mms.MESSAGE_BOX_SENT)
SqliteWrapper.update(context, context.contentResolver, uri, values, null, null)
}
else -> {
Timber.v("MMS has failed to send, marking it as so in the database")
try {
val messageId = ContentUris.parseId(uri)
val values = ContentValues(1)
values.put(Telephony.Mms.MESSAGE_BOX, Telephony.Mms.MESSAGE_BOX_FAILED)
SqliteWrapper.update(context, context.contentResolver, Telephony.Mms.CONTENT_URI, values,
"${Telephony.Mms._ID} = ?", arrayOf(messageId.toString()))
// TODO this query isn't able to find any results
// Need to figure out why the message isn't appearing in the PendingMessages Uri,
// so that we can properly assign the error type
val errorTypeValues = ContentValues(1)
errorTypeValues.put(Telephony.MmsSms.PendingMessages.ERROR_TYPE, Telephony.MmsSms.ERR_TYPE_GENERIC_PERMANENT)
SqliteWrapper.update(context, context.contentResolver, Telephony.MmsSms.PendingMessages.CONTENT_URI,
errorTypeValues, "${Telephony.MmsSms.PendingMessages.MSG_ID} = ?", arrayOf(messageId.toString()))
} catch (e: MmsException) {
e.printStackTrace()
}
}
}
val filePath = intent.getStringExtra(Transaction.EXTRA_FILE_PATH)
Timber.v(filePath)
File(filePath).delete()
Uri.parse(intent.getStringExtra("content_uri"))?.let { uri ->
val pendingResult = goAsync()
syncMessage.execute(uri) { pendingResult.finish() }
}
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.moez.QKSMS.interactor.SyncMessage
import dagger.android.AndroidInjection
import javax.inject.Inject
class MmsUpdatedReceiver : BroadcastReceiver() {
companion object {
const val URI = "uri"
}
@Inject lateinit var syncMessage: SyncMessage
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
intent.getStringExtra(URI)?.let { uriString ->
val pendingResult = goAsync()
syncMessage.execute(Uri.parse(uriString)) { pendingResult.finish() }
}
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.moez.QKSMS.util.NightModeManager
import dagger.android.AndroidInjection
import javax.inject.Inject
class NightModeReceiver : BroadcastReceiver() {
@Inject lateinit var nightModeManager: NightModeManager
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
nightModeManager.updateCurrentTheme()
}
}
@@ -0,0 +1,58 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.support.v4.app.RemoteInput
import com.moez.QKSMS.compat.SubscriptionManagerCompat
import com.moez.QKSMS.interactor.MarkRead
import com.moez.QKSMS.interactor.SendMessage
import com.moez.QKSMS.repository.ConversationRepository
import com.moez.QKSMS.repository.MessageRepository
import dagger.android.AndroidInjection
import javax.inject.Inject
class RemoteMessagingReceiver : BroadcastReceiver() {
@Inject lateinit var conversationRepo: ConversationRepository
@Inject lateinit var markRead: MarkRead
@Inject lateinit var messageRepo: MessageRepository
@Inject lateinit var sendMessage: SendMessage
@Inject lateinit var subscriptionManager: SubscriptionManagerCompat
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
val remoteInput = RemoteInput.getResultsFromIntent(intent) ?: return
val bundle = intent.extras ?: return
val threadId = bundle.getLong("threadId")
val body = remoteInput.getCharSequence("body").toString()
markRead.execute(listOf(threadId))
val lastMessage = messageRepo.getMessages(threadId).lastOrNull()
val subId = subscriptionManager.activeSubscriptionInfoList.firstOrNull { it.subscriptionId == lastMessage?.subId }?.subscriptionId ?: -1
val addresses = conversationRepo.getConversation(threadId)?.recipients?.map { it.address } ?: return
val pendingRepository = goAsync()
sendMessage.execute(SendMessage.Params(subId, threadId, addresses, body)) { pendingRepository.finish() }
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.moez.QKSMS.interactor.RetrySending
import com.moez.QKSMS.repository.MessageRepository
import dagger.android.AndroidInjection
import javax.inject.Inject
class SendSmsReceiver : BroadcastReceiver() {
@Inject lateinit var messageRepo: MessageRepository
@Inject lateinit var retrySending: RetrySending
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
val id = intent.getLongExtra("id", 0L)
messageRepo.getMessage(id)?.let { message ->
val result = goAsync()
retrySending.execute(message) { result.finish() }
}
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.moez.QKSMS.interactor.MarkDelivered
import com.moez.QKSMS.interactor.MarkDeliveryFailed
import dagger.android.AndroidInjection
import javax.inject.Inject
class SmsDeliveredReceiver : BroadcastReceiver() {
@Inject lateinit var markDelivered: MarkDelivered
@Inject lateinit var markDeliveryFailed: MarkDeliveryFailed
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
val id = intent.getLongExtra("id", 0L)
when (resultCode) {
// TODO notify about delivery
Activity.RESULT_OK -> {
val pendingResult = goAsync()
markDelivered.execute(id) { pendingResult.finish() }
}
// TODO notify about delivery failure
Activity.RESULT_CANCELED -> {
val pendingResult = goAsync()
markDeliveryFailed.execute(MarkDeliveryFailed.Params(id, resultCode)) { pendingResult.finish() }
}
}
}
}
@@ -0,0 +1,55 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.moez.QKSMS.interactor.SyncMessage
import dagger.android.AndroidInjection
import javax.inject.Inject
/**
* When the SMS contentprovider is changed by a process other than us, we need to sync the Uri that
* was changed.
*
* This can happen if a message is sent through something like Pushbullet or Google Assistant.
*
* This only works on API 24+, so to fully solve this problem we'll need a smarter way of running
* partial syncs on older devices.
*
* https://developer.android.com/reference/android/provider/Telephony.Sms.Intents.html#ACTION_EXTERNAL_PROVIDER_CHANGE
*/
class SmsProviderChangedReceiver : BroadcastReceiver() {
@Inject lateinit var syncMessage: SyncMessage
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
// Obtain the uri for the changed data
// If the value is null, don't continue
val uri = intent.data ?: return
// Sync the message to our realm
val pendingResult = goAsync()
syncMessage.execute(uri) { pendingResult.finish() }
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.provider.Telephony.Sms
import com.moez.QKSMS.interactor.ReceiveSms
import dagger.android.AndroidInjection
import javax.inject.Inject
class SmsReceiver : BroadcastReceiver() {
@Inject lateinit var receiveMessage: ReceiveSms
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
Sms.Intents.getMessagesFromIntent(intent)?.let { messages ->
val subId = intent.extras?.getInt("subscription", -1) ?: -1
val pendingResult = goAsync()
receiveMessage.execute(ReceiveSms.Params(subId, messages), { pendingResult.finish() })
}
}
}
@@ -0,0 +1,56 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.receiver
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.telephony.SmsManager
import com.moez.QKSMS.interactor.MarkFailed
import com.moez.QKSMS.interactor.MarkSent
import dagger.android.AndroidInjection
import javax.inject.Inject
class SmsSentReceiver : BroadcastReceiver() {
@Inject lateinit var markSent: MarkSent
@Inject lateinit var markFailed: MarkFailed
override fun onReceive(context: Context, intent: Intent) {
AndroidInjection.inject(this, context)
val id = intent.getLongExtra("id", 0L)
when (resultCode) {
Activity.RESULT_OK -> {
val pendingResult = goAsync()
markSent.execute(id) { pendingResult.finish() }
}
SmsManager.RESULT_ERROR_GENERIC_FAILURE,
SmsManager.RESULT_ERROR_NO_SERVICE,
SmsManager.RESULT_ERROR_NULL_PDU,
SmsManager.RESULT_ERROR_RADIO_OFF -> {
val pendingResult = goAsync()
markFailed.execute(MarkFailed.Params(id, resultCode)) { pendingResult.finish() }
}
}
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.repository
import android.content.Context
import android.net.Uri
import android.provider.BaseColumns
import android.provider.ContactsContract
import com.moez.QKSMS.extensions.asFlowable
import com.moez.QKSMS.extensions.mapNotNull
import com.moez.QKSMS.model.Contact
import io.reactivex.Flowable
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import io.realm.Realm
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ContactRepositoryImpl @Inject constructor(val context: Context) : ContactRepository {
override fun findContactUri(address: String): Single<Uri> {
return Flowable.just(address)
.map {
when {
address.contains('@') -> Uri.withAppendedPath(ContactsContract.CommonDataKinds.Email.CONTENT_FILTER_URI, Uri.encode(address))
else -> Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address))
}
}
.mapNotNull { uri -> context.contentResolver.query(uri, arrayOf(BaseColumns._ID), null, null, null) }
.flatMap { cursor -> cursor.asFlowable() }
.firstOrError()
.map { cursor -> cursor.getString(cursor.getColumnIndexOrThrow(BaseColumns._ID)) }
.map { id -> Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_URI, id) }
}
override fun getUnmanagedContacts(): Flowable<List<Contact>> {
val realm = Realm.getDefaultInstance()
return 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())
}
}
@@ -0,0 +1,292 @@
package com.moez.QKSMS.repository
import android.content.ContentUris
import android.content.Context
import android.provider.Telephony
import android.telephony.PhoneNumberUtils
import com.moez.QKSMS.compat.TelephonyCompat
import com.moez.QKSMS.extensions.anyOf
import com.moez.QKSMS.extensions.map
import com.moez.QKSMS.filter.ConversationFilter
import com.moez.QKSMS.mapper.CursorToConversation
import com.moez.QKSMS.mapper.CursorToRecipient
import com.moez.QKSMS.model.Contact
import com.moez.QKSMS.model.Conversation
import com.moez.QKSMS.model.Message
import com.moez.QKSMS.model.SearchResult
import com.moez.QKSMS.util.tryOrNull
import io.realm.Case
import io.realm.Realm
import io.realm.RealmResults
import io.realm.Sort
import javax.inject.Inject
class ConversationRepositoryImpl @Inject constructor(
private val context: Context,
private val conversationFilter: ConversationFilter,
private val cursorToConversation: CursorToConversation,
private val cursorToRecipient: CursorToRecipient
) : ConversationRepository {
override fun getConversations(archived: Boolean): RealmResults<Conversation> {
return Realm.getDefaultInstance()
.where(Conversation::class.java)
.notEqualTo("id", 0L)
.greaterThan("count", 0)
.equalTo("archived", archived)
.equalTo("blocked", false)
.isNotEmpty("recipients")
.sort("date", Sort.DESCENDING)
.findAllAsync()
}
override fun getConversationsSnapshot(): List<Conversation> {
val realm = Realm.getDefaultInstance()
return realm.copyFromRealm(realm.where(Conversation::class.java)
.notEqualTo("id", 0L)
.greaterThan("count", 0)
.equalTo("archived", false)
.equalTo("blocked", false)
.isNotEmpty("recipients")
.sort("date", Sort.DESCENDING)
.findAll())
}
override fun setConversationName(id: Long, name: String) {
Realm.getDefaultInstance().use { realm ->
realm.executeTransaction {
realm.where(Conversation::class.java)
.equalTo("id", id)
.findFirst()
?.name = name
}
}
}
override fun searchConversations(query: String): List<SearchResult> {
val conversations = getConversationsSnapshot()
val messagesByConversation = Realm.getDefaultInstance()
.where(Message::class.java)
.contains("body", query, Case.INSENSITIVE)
.findAll()
.groupBy { message -> message.threadId }
.filter { (threadId, _) -> conversations.firstOrNull { it.id == threadId } != null }
.map { (threadId, messages) -> Pair(conversations.first { it.id == threadId }, messages.size) }
.map { (conversation, messages) -> SearchResult(query, conversation, messages) }
.sortedByDescending { result -> result.messages }
return conversations
.filter { conversation -> conversationFilter.filter(conversation, query) }
.map { conversation -> SearchResult(query, conversation, 0) }
.plus(messagesByConversation)
}
override fun getBlockedConversations(): RealmResults<Conversation> {
return Realm.getDefaultInstance()
.where(Conversation::class.java)
.equalTo("blocked", true)
.findAllAsync()
}
override fun getConversationAsync(threadId: Long): Conversation {
return Realm.getDefaultInstance()
.where(Conversation::class.java)
.equalTo("id", threadId)
.findFirstAsync()
}
override fun getConversation(threadId: Long): Conversation? {
return Realm.getDefaultInstance()
.where(Conversation::class.java)
.equalTo("id", threadId)
.findFirst()
}
override fun getThreadId(recipient: String): Long? {
return getThreadId(listOf(recipient))
}
override fun getThreadId(recipients: Collection<String>): Long? {
return Realm.getDefaultInstance().use { realm ->
realm.where(Conversation::class.java)
.findAll()
.firstOrNull { conversation ->
conversation.recipients.size == recipients.size && conversation.recipients.map { it.address }.all { address ->
recipients.any { recipient -> PhoneNumberUtils.compare(recipient, address) }
}
}
?.id
}
}
override fun getOrCreateConversation(threadId: Long): Conversation? {
return getConversation(threadId) ?: getConversationFromCp(threadId)
}
override fun getOrCreateConversation(address: String): Conversation? {
return getOrCreateConversation(listOf(address))
}
override fun getOrCreateConversation(addresses: List<String>): Conversation? {
return tryOrNull { TelephonyCompat.getOrCreateThreadId(context, addresses.toSet()) }
?.takeIf { threadId -> threadId != 0L }
?.let { threadId ->
var conversation = getConversation(threadId)
if (conversation != null) conversation = Realm.getDefaultInstance().copyFromRealm(conversation)
conversation ?: getConversationFromCp(threadId)
}
}
override fun saveDraft(threadId: Long, draft: String) {
Realm.getDefaultInstance().use { realm ->
val conversation = realm.where(Conversation::class.java)
.equalTo("id", threadId)
.findFirst()
conversation?.let {
realm.executeTransaction {
conversation.draft = draft
}
}
}
}
override fun updateConversations(vararg threadIds: Long) {
Realm.getDefaultInstance().use { realm ->
realm.refresh()
threadIds.forEach { threadId ->
val conversation = realm
.where(Conversation::class.java)
.equalTo("id", threadId)
.findFirst() ?: return
val messages = realm
.where(Message::class.java)
.equalTo("threadId", threadId)
.sort("date", Sort.DESCENDING)
.findAll()
val message = messages.firstOrNull()
realm.executeTransaction {
conversation.count = messages.size
conversation.date = message?.date ?: 0
conversation.snippet = message?.getSummary() ?: ""
conversation.read = message?.read ?: true
conversation.me = message?.isMe() ?: false
}
}
}
}
override fun markArchived(vararg threadIds: Long) {
Realm.getDefaultInstance().use { realm ->
val conversations = realm.where(Conversation::class.java)
.anyOf("id", threadIds)
.findAll()
realm.executeTransaction {
conversations.forEach { it.archived = true }
}
}
}
override fun markUnarchived(vararg threadIds: Long) {
Realm.getDefaultInstance().use { realm ->
val conversations = realm.where(Conversation::class.java)
.anyOf("id", threadIds)
.findAll()
realm.executeTransaction {
conversations.forEach { it.archived = false }
}
}
}
override fun markBlocked(vararg threadIds: Long) {
Realm.getDefaultInstance().use { realm ->
val conversations = realm.where(Conversation::class.java)
.anyOf("id", threadIds)
.findAll()
realm.executeTransaction {
conversations.forEach { it.blocked = true }
}
}
}
override fun markUnblocked(vararg threadIds: Long) {
Realm.getDefaultInstance().use { realm ->
val conversations = realm.where(Conversation::class.java)
.anyOf("id", threadIds)
.equalTo("archived", false)
.findAll()
realm.executeTransaction {
conversations.forEach { it.blocked = false }
}
}
}
override fun deleteConversations(vararg threadIds: Long) {
Realm.getDefaultInstance().use { realm ->
val conversation = realm.where(Conversation::class.java).anyOf("id", threadIds).findAll()
val messages = realm.where(Message::class.java).anyOf("threadId", threadIds).findAll()
realm.executeTransaction {
conversation.deleteAllFromRealm()
messages.deleteAllFromRealm()
}
}
threadIds.forEach { threadId ->
val uri = ContentUris.withAppendedId(Telephony.Threads.CONTENT_URI, threadId)
context.contentResolver.delete(uri, null, null)
}
}
/**
* Returns a [Conversation] from the system SMS ContentProvider, based on the [threadId]
*
* It should be noted that even if we have a valid [threadId], that does not guarantee that
* we can return a [Conversation]. On some devices, the ContentProvider won't return the
* conversation unless it contains at least 1 message
*/
private fun getConversationFromCp(threadId: Long): Conversation? {
return cursorToConversation.getConversationCursor(threadId)
?.takeIf { cursor -> cursor.moveToFirst() }
?.use { cursor ->
val conversation = cursorToConversation.map(cursor)
val realm = Realm.getDefaultInstance()
val contacts = realm.copyFromRealm(realm.where(Contact::class.java).findAll())
val recipients = conversation.recipients
.map { recipient -> recipient.id }
.map { id -> cursorToRecipient.getRecipientCursor(id) }
.mapNotNull { recipientCursor ->
// Map the recipient cursor to a list of recipients
recipientCursor?.use { recipientCursor.map { cursorToRecipient.map(recipientCursor) } }
}
.flatten()
.map { recipient ->
recipient.apply {
contact = contacts.firstOrNull {
it.numbers.any { PhoneNumberUtils.compare(recipient.address, it.address) }
}
}
}
conversation.recipients.clear()
conversation.recipients.addAll(recipients)
realm.executeTransaction { it.insertOrUpdate(conversation) }
realm.close()
conversation
}
}
}
@@ -0,0 +1,141 @@
package com.moez.QKSMS.repository
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import android.provider.MediaStore
import android.provider.MediaStore.Images
import android.support.media.ExifInterface
import com.moez.QKSMS.util.tryOrNull
import javax.inject.Inject
class ImageRepostoryImpl @Inject constructor(private val context: Context) : ImageRepository {
override fun loadImage(uri: Uri): Bitmap? {
val exif = ExifInterface(context.contentResolver.openInputStream(uri))
val bitmap = BitmapFactory.decodeStream(context.contentResolver.openInputStream(uri))
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
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)
}
override fun saveImage(uri: Uri) {
val bitmap = tryOrNull { MediaStore.Images.Media.getBitmap(context.contentResolver, uri) }
var title = ""
var description = ""
when (uri.scheme) {
"file" -> title = uri.lastPathSegment
"content" -> {
val projection = arrayOf(Images.Media.TITLE, Images.Media.DESCRIPTION)
context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
title = cursor.getString(0)
description = cursor.getString(1)
}
}
}
}
insertImage(context.contentResolver, bitmap, title, description)
}
/**
* Modified from [android.provider.MediaStore.Images.Media.insertImage] to add date fields so
* that the saved image is put at the start of the gallery
*/
private fun insertImage(cr: ContentResolver, source: Bitmap?, title: String, description: String): String? {
val values = ContentValues()
values.put(Images.Media.TITLE, title)
values.put(Images.Media.DISPLAY_NAME, title)
values.put(Images.Media.DESCRIPTION, description)
values.put(Images.Media.MIME_TYPE, "image/jpeg")
// Add the date meta data to ensure the image is added at the front of the gallery
values.put(Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
values.put(Images.Media.DATE_TAKEN, System.currentTimeMillis())
var uri: Uri? = null
try {
uri = cr.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
if (source != null) {
cr.openOutputStream(uri).use { imageOut ->
source.compress(Bitmap.CompressFormat.JPEG, 100, imageOut)
}
val id = ContentUris.parseId(uri)
// Wait until MINI_KIND thumbnail is generated.
val miniThumb = Images.Thumbnails.getThumbnail(cr, id, Images.Thumbnails.MINI_KIND, null)
// This is for backward compatibility.
storeThumbnail(cr, miniThumb, id, 50f, 50f, Images.Thumbnails.MICRO_KIND)
} else {
cr.delete(uri, null, null)
uri = null
}
} catch (e: Exception) {
if (uri != null) {
cr.delete(uri, null, null)
uri = null
}
}
return uri?.toString()
}
/**
* Modified from [android.provider.MediaStore.Images.Media.insertImage] to add date fields so
* that the saved image is put at the start of the gallery
*/
private fun storeThumbnail(cr: ContentResolver, source: Bitmap, id: Long, width: Float, height: Float, kind: Int): Bitmap? {
val matrix = Matrix()
val scaleX = width / source.width
val scaleY = height / source.height
matrix.setScale(scaleX, scaleY)
val thumb = Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true)
val values = ContentValues(4)
values.put(Images.Thumbnails.KIND, kind)
values.put(Images.Thumbnails.IMAGE_ID, id.toInt())
values.put(Images.Thumbnails.HEIGHT, thumb.height)
values.put(Images.Thumbnails.WIDTH, thumb.width)
val url = cr.insert(Images.Thumbnails.EXTERNAL_CONTENT_URI, values)
return tryOrNull {
val thumbOut = cr.openOutputStream(url)
thumb.compress(Bitmap.CompressFormat.JPEG, 100, thumbOut)
thumbOut.close()
thumb
}
}
}
@@ -0,0 +1,521 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.repository
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.os.Build
import android.provider.Telephony
import android.telephony.SmsManager
import com.google.android.mms.ContentType
import com.klinker.android.send_message.BroadcastUtils
import com.klinker.android.send_message.Settings
import com.klinker.android.send_message.StripAccents
import com.klinker.android.send_message.Transaction
import com.moez.QKSMS.extensions.anyOf
import com.moez.QKSMS.extensions.insertOrUpdate
import com.moez.QKSMS.manager.KeyManager
import com.moez.QKSMS.model.Attachment
import com.moez.QKSMS.model.Conversation
import com.moez.QKSMS.model.Message
import com.moez.QKSMS.model.MmsPart
import com.moez.QKSMS.util.Preferences
import io.realm.Case
import io.realm.Realm
import io.realm.RealmResults
import io.realm.Sort
import timber.log.Timber
import java.io.ByteArrayOutputStream
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MessageRepositoryImpl @Inject constructor(
private val context: Context,
private val messageIds: KeyManager,
private val conversationRepo: ConversationRepository,
private val imageRepository: ImageRepository,
private val prefs: Preferences) : MessageRepository {
override fun getMessages(threadId: Long, query: String): RealmResults<Message> {
return Realm.getDefaultInstance()
.where(Message::class.java)
.equalTo("threadId", threadId)
.let { if (query.isEmpty()) it else it.contains("body", query, Case.INSENSITIVE) }
.sort("date")
.findAllAsync()
}
override fun getMessage(id: Long): Message? {
return Realm.getDefaultInstance()
.where(Message::class.java)
.equalTo("id", id)
.findFirst()
}
override fun getMessageForPart(id: Long): Message? {
return Realm.getDefaultInstance()
.where(Message::class.java)
.equalTo("parts.id", id)
.findFirst()
}
override fun getUnreadCount(): Long {
return Realm.getDefaultInstance()
.where(Conversation::class.java)
.equalTo("archived", false)
.equalTo("blocked", false)
.equalTo("read", false)
.count()
}
override fun getPart(id: Long): MmsPart? {
return Realm.getDefaultInstance()
.where(MmsPart::class.java)
.equalTo("id", id)
.findFirst()
}
override fun getPartsForConversation(threadId: Long): RealmResults<MmsPart> {
return Realm.getDefaultInstance()
.where(MmsPart::class.java)
.equalTo("messages.threadId", threadId)
.beginGroup()
.contains("type", "image/")
.or()
.contains("type", "video/")
.endGroup()
.sort("id", Sort.DESCENDING)
.findAllAsync()
}
/**
* Retrieves the list of messages which should be shown in the notification
* for a given conversation
*/
override fun getUnreadUnseenMessages(threadId: Long): RealmResults<Message> {
return Realm.getDefaultInstance()
.also { it.refresh() }
.where(Message::class.java)
.equalTo("seen", false)
.equalTo("read", false)
.equalTo("threadId", threadId)
.sort("date")
.findAll()
}
override fun getUnreadMessages(threadId: Long): RealmResults<Message> {
return Realm.getDefaultInstance()
.where(Message::class.java)
.equalTo("read", false)
.equalTo("threadId", threadId)
.sort("date")
.findAll()
}
override fun markAllSeen() {
val realm = Realm.getDefaultInstance()
val messages = realm.where(Message::class.java).equalTo("seen", false).findAll()
realm.executeTransaction { messages.forEach { message -> message.seen = true } }
realm.close()
}
override fun markSeen(threadId: Long) {
val realm = Realm.getDefaultInstance()
val messages = realm.where(Message::class.java)
.equalTo("threadId", threadId)
.equalTo("seen", false)
.findAll()
realm.executeTransaction {
messages.forEach { message ->
message.seen = true
}
}
realm.close()
}
override fun markRead(vararg threadIds: Long) {
Realm.getDefaultInstance()?.use { realm ->
val messages = realm.where(Message::class.java)
.anyOf("threadId", threadIds)
.beginGroup()
.equalTo("read", false)
.or()
.equalTo("seen", false)
.endGroup()
.findAll()
realm.executeTransaction {
messages.forEach { message ->
message.seen = true
message.read = true
}
}
}
val values = ContentValues()
values.put(Telephony.Sms.SEEN, true)
values.put(Telephony.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)
} catch (exception: Exception) {
Timber.w(exception)
}
}
}
override fun markUnread(vararg threadIds: Long) {
Realm.getDefaultInstance()?.use { realm ->
val conversation = realm.where(Conversation::class.java)
.anyOf("id", threadIds)
.equalTo("read", true)
.findAll()
realm.executeTransaction {
conversation.forEach { it.read = false }
}
}
}
override fun sendSmsAndPersist(subId: Int, threadId: Long, address: String, body: String) {
if (prefs.sendDelay.get() != Preferences.SEND_DELAY_NONE) {
val delay = when (prefs.sendDelay.get()) {
Preferences.SEND_DELAY_SHORT -> 3000
Preferences.SEND_DELAY_MEDIUM -> 5000
Preferences.SEND_DELAY_LONG -> 10000
else -> 0
}
val sendTime = System.currentTimeMillis() + delay
val message = insertSentSms(subId, threadId, address, body, sendTime)
val intent = getIntentForDelayedSms(message.id)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, sendTime, intent)
} else {
alarmManager.setExact(AlarmManager.RTC_WAKEUP, sendTime, intent)
}
} else {
val message = insertSentSms(subId, threadId, address, body, System.currentTimeMillis())
sendSms(message)
}
}
override fun sendSms(message: Message) {
val smsManager = message.subId.takeIf { it != -1 }
?.let(SmsManager::getSmsManagerForSubscriptionId)
?: SmsManager.getDefault()
val parts = smsManager.divideMessage(if (prefs.unicode.get()) StripAccents.stripAccents(message.body) else message.body)
?: arrayListOf()
val sentIntents = parts.map {
val action = "com.moez.QKSMS.SMS_SENT"
val intent = Intent(action).putExtra("id", message.id)
BroadcastUtils.addClassName(context, intent, action)
PendingIntent.getBroadcast(context, message.id.toInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
val deliveredIntents = parts.map {
val action = "com.moez.QKSMS.SMS_DELIVERED"
val intent = Intent(action).putExtra("id", message.id)
BroadcastUtils.addClassName(context, intent, action)
val pendingIntent = PendingIntent.getBroadcast(context, message.id.toInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT)
if (prefs.delivery.get()) pendingIntent else null
}
smsManager.sendMultipartTextMessage(message.address, null, parts, ArrayList(sentIntents), ArrayList(deliveredIntents))
}
override fun sendMms(subId: Int, threadId: Long, addresses: List<String>, body: String, attachments: List<Attachment>) {
val settings = Settings().apply { subscriptionId = subId }
val message = com.klinker.android.send_message.Message(body, addresses.toTypedArray())
// Add the GIFs as attachments. The app currently can't compress them, which may result
// in a lot of these messages failing to send
// TODO Add support for GIF compression
attachments
.filter { attachment -> attachment.isGif(context) }
.map { attachment -> attachment.getUri() }
.map { uri -> context.contentResolver.openInputStream(uri) }
.map { inputStream -> inputStream.readBytes() }
.forEach { bitmap -> message.addMedia(bitmap, ContentType.IMAGE_GIF) }
// Compress the images and add them as attachments
var totalImageBytes = 0
attachments
.filter { attachment -> !attachment.isGif(context) }
.mapNotNull { attachment -> attachment.getUri() }
.mapNotNull { uri -> imageRepository.loadImage(uri) }
.also { totalImageBytes = it.sumBy { it.allocationByteCount } }
.map { bitmap ->
val byteRatio = bitmap.allocationByteCount / totalImageBytes.toFloat()
shrink(bitmap, (prefs.mmsSize.get() * 1024 * byteRatio).toInt())
}
.forEach { bitmap -> message.addMedia(bitmap, ContentType.IMAGE_JPEG) }
val transaction = Transaction(context, settings)
transaction.sendNewMessage(message, threadId)
}
private fun shrink(src: Bitmap, maxBytes: Int): ByteArray {
var step = 0.0
val factor = 0.5
val quality = 60
val height = src.height
val width = src.width
val stream = ByteArrayOutputStream()
src.compress(Bitmap.CompressFormat.JPEG, 100, stream)
while (maxBytes > 0 && stream.size() > maxBytes) {
step++
val scale = Math.pow(factor, step)
stream.reset()
Bitmap.createScaledBitmap(src, (width * scale).toInt(), (height * scale).toInt(), false)
.compress(Bitmap.CompressFormat.JPEG, quality, stream)
}
return stream.toByteArray()
}
override fun cancelDelayedSms(id: Long) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.cancel(getIntentForDelayedSms(id))
}
private fun getIntentForDelayedSms(id: Long): PendingIntent {
val action = "com.moez.QKSMS.SEND_SMS"
val intent = Intent(action).putExtra("id", id)
BroadcastUtils.addClassName(context, intent, action)
return PendingIntent.getBroadcast(context, id.toInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
override fun insertSentSms(subId: Int, threadId: Long, address: String, body: String, date: Long): Message {
// Insert the message to Realm
val message = Message().apply {
this.threadId = threadId
this.address = address
this.body = body
this.date = date
this.subId = subId
id = messageIds.newId()
boxId = Telephony.Sms.MESSAGE_TYPE_OUTBOX
type = "sms"
read = true
seen = true
}
val realm = Realm.getDefaultInstance()
var managedMessage: Message? = null
realm.executeTransaction { managedMessage = realm.copyToRealmOrUpdate(message) }
// Insert the message to the native content provider
val values = ContentValues().apply {
put(Telephony.Sms.ADDRESS, address)
put(Telephony.Sms.BODY, body)
put(Telephony.Sms.DATE, System.currentTimeMillis())
put(Telephony.Sms.READ, true)
put(Telephony.Sms.SEEN, true)
put(Telephony.Sms.TYPE, Telephony.Sms.MESSAGE_TYPE_OUTBOX)
put(Telephony.Sms.THREAD_ID, threadId)
put(Telephony.Sms.SUBSCRIPTION_ID, subId)
}
val uri = context.contentResolver.insert(Telephony.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
//
// We do this after inserting the message because it might be slow, and we want the message
// to be inserted into Realm immediately. We don't need to do this after receiving one
realm.executeTransaction { managedMessage?.takeIf { it.isValid }?.contentId = uri.lastPathSegment.toLong() }
realm.close()
return message
}
override fun insertReceivedSms(subId: Int, address: String, body: String, sentTime: Long): Message {
// Insert the message to the native content provider
val values = ContentValues().apply {
put(Telephony.Sms.ADDRESS, address)
put(Telephony.Sms.BODY, body)
put(Telephony.Sms.DATE_SENT, sentTime)
put(Telephony.Sms.SUBSCRIPTION_ID, subId)
}
val uri = context.contentResolver.insert(Telephony.Sms.Inbox.CONTENT_URI, values)
// Insert the message to Realm
return Message().apply {
this.address = address
this.body = body
this.dateSent = sentTime
this.date = System.currentTimeMillis()
this.subId = subId
id = messageIds.newId()
threadId = conversationRepo.getOrCreateConversation(address)?.id ?: 0L
contentId = uri.lastPathSegment.toLong()
boxId = Telephony.Sms.MESSAGE_TYPE_INBOX
type = "sms"
insertOrUpdate()
}
}
/**
* Marks the message as sending, in case we need to retry sending it
*/
override fun markSending(id: Long) {
Realm.getDefaultInstance().use { realm ->
realm.refresh()
val message = realm.where(Message::class.java).equalTo("id", id).findFirst()
message?.let {
// Update the message in realm
realm.executeTransaction {
message.boxId = Telephony.Sms.MESSAGE_TYPE_OUTBOX
}
// Update the message in the native ContentProvider
val values = ContentValues()
values.put(Telephony.Sms.TYPE, Telephony.Sms.MESSAGE_TYPE_OUTBOX)
context.contentResolver.update(message.getUri(), values, null, null)
}
}
}
override fun markSent(id: Long) {
Realm.getDefaultInstance().use { realm ->
realm.refresh()
val message = realm.where(Message::class.java).equalTo("id", id).findFirst()
message?.let {
// Update the message in realm
realm.executeTransaction {
message.boxId = Telephony.Sms.MESSAGE_TYPE_SENT
}
// Update the message in the native ContentProvider
val values = ContentValues()
values.put(Telephony.Sms.TYPE, Telephony.Sms.MESSAGE_TYPE_SENT)
context.contentResolver.update(message.getUri(), values, null, null)
}
}
}
override fun markFailed(id: Long, resultCode: Int) {
Realm.getDefaultInstance().use { realm ->
realm.refresh()
val message = realm.where(Message::class.java).equalTo("id", id).findFirst()
message?.let {
// Update the message in realm
realm.executeTransaction {
message.boxId = Telephony.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)
context.contentResolver.update(message.getUri(), values, null, null)
}
}
}
override fun markDelivered(id: Long) {
Realm.getDefaultInstance().use { realm ->
realm.refresh()
val message = realm.where(Message::class.java).equalTo("id", id).findFirst()
message?.let {
// Update the message in realm
realm.executeTransaction {
message.deliveryStatus = Telephony.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)
context.contentResolver.update(message.getUri(), values, null, null)
}
}
}
override fun markDeliveryFailed(id: Long, resultCode: Int) {
Realm.getDefaultInstance().use { realm ->
realm.refresh()
val message = realm.where(Message::class.java).equalTo("id", id).findFirst()
message?.let {
// Update the message in realm
realm.executeTransaction {
message.deliveryStatus = Telephony.Sms.STATUS_FAILED
message.dateSent = System.currentTimeMillis()
message.read = true
message.errorCode = resultCode
}
// 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)
context.contentResolver.update(message.getUri(), values, null, null)
}
}
}
override fun deleteMessages(vararg messageIds: Long) {
Realm.getDefaultInstance().use { realm ->
realm.refresh()
val messages = realm.where(Message::class.java)
.anyOf("id", messageIds)
.findAll()
val uris = messages.map { it.getUri() }
realm.executeTransaction { messages.deleteAllFromRealm() }
uris.forEach { uri -> context.contentResolver.delete(uri, null, null) }
}
}
}
@@ -0,0 +1,240 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.repository
import android.content.ContentResolver
import android.content.ContentUris
import android.net.Uri
import android.provider.Telephony
import android.telephony.PhoneNumberUtils
import com.f2prateek.rx.preferences2.RxSharedPreferences
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.CursorToConversation
import com.moez.QKSMS.mapper.CursorToMessage
import com.moez.QKSMS.mapper.CursorToRecipient
import com.moez.QKSMS.model.Contact
import com.moez.QKSMS.model.Conversation
import com.moez.QKSMS.model.Message
import com.moez.QKSMS.model.MmsPart
import com.moez.QKSMS.model.Recipient
import com.moez.QKSMS.model.SyncLog
import com.moez.QKSMS.util.tryOrNull
import io.reactivex.subjects.BehaviorSubject
import io.reactivex.subjects.Subject
import io.realm.Realm
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SyncRepositoryImpl @Inject constructor(
private val contentResolver: ContentResolver,
private val conversationRepo: ConversationRepository,
private val messageRepo: MessageRepository,
private val cursorToConversation: CursorToConversation,
private val cursorToMessage: CursorToMessage,
private val cursorToRecipient: CursorToRecipient,
private val cursorToContact: CursorToContact,
private val keys: KeyManager,
private val rxPrefs: RxSharedPreferences
) : SyncRepository {
/**
* Holds data that should be persisted across full syncs
*/
private data class PersistedData(
val id: Long,
val archived: Boolean,
val blocked: Boolean,
val name: String)
override val syncProgress: Subject<SyncRepository.SyncProgress> = BehaviorSubject.createDefault(SyncRepository.SyncProgress.Idle())
override fun syncMessages() {
// If the sync is already running, don't try to do another one
if (syncProgress.blockingFirst() is SyncRepository.SyncProgress.Running) return
syncProgress.onNext(SyncRepository.SyncProgress.Running(0f))
val realm = Realm.getDefaultInstance()
realm.beginTransaction()
var persistedData = realm.where(Conversation::class.java)
.beginGroup()
.equalTo("archived", true)
.or()
.equalTo("blocked", true)
.or()
.isNotEmpty("name")
.endGroup()
.findAll()
.map { PersistedData(it.id, it.archived, it.blocked, it.name) }
realm.delete(Contact::class.java)
realm.delete(Conversation::class.java)
realm.delete(Message::class.java)
realm.delete(MmsPart::class.java)
realm.delete(Recipient::class.java)
keys.reset()
// Sync messages
cursorToMessage.getMessagesCursor()?.use { messageCursor ->
val messageColumns = CursorToMessage.MessageColumns(messageCursor)
val messages = messageCursor.map { cursor -> cursorToMessage.map(Pair(cursor, messageColumns)) }
realm.insertOrUpdate(messages)
}
// Migrate blocked conversations from 2.7.3
val oldBlockedSenders = rxPrefs.getStringSet("pref_key_blocked_senders")
persistedData += oldBlockedSenders.get()
.map { threadIdString -> threadIdString.toLong() }
.filter { threadId -> persistedData.none { it.id == threadId } }
.map { threadId -> PersistedData(threadId, false, true, "") }
// Sync conversations
cursorToConversation.getConversationsCursor()?.use { conversationCursor ->
val conversations = conversationCursor
.map { cursor -> cursorToConversation.map(cursor) }
persistedData.forEach { data ->
val conversation = conversations.firstOrNull { conversation -> conversation.id == data.id }
conversation?.archived = data.archived
conversation?.blocked = data.blocked
conversation?.name = data.name
}
realm.insertOrUpdate(conversations)
}
// Sync recipients
cursorToRecipient.getRecipientCursor()?.use { recipientCursor ->
val contacts = realm.copyToRealm(getContacts())
val recipients = recipientCursor
.map { cursor -> cursorToRecipient.map(cursor) }
.map { recipient ->
recipient.apply {
contact = contacts.firstOrNull { contact ->
contact.numbers.any { PhoneNumberUtils.compare(recipient.address, it.address) }
}
}
}
realm.insertOrUpdate(recipients)
}
realm.insert(SyncLog())
realm.commitTransaction()
realm.close()
// Only delete this after the sync has successfully completed
oldBlockedSenders.delete()
syncProgress.onNext(SyncRepository.SyncProgress.Idle())
}
override fun syncMessage(uri: Uri): Message? {
// If we don't have a valid type, return null
val type = when {
uri.toString().contains("mms") -> "mms"
uri.toString().contains("sms") -> "sms"
else -> return null
}
// If we don't have a valid id, return null
val id = tryOrNull { ContentUris.parseId(uri) } ?: return null
// Check if the message already exists, so we can reuse the id
val existingId = Realm.getDefaultInstance().use { realm ->
realm.refresh()
realm.where(Message::class.java)
.equalTo("type", type)
.equalTo("contentId", id)
.findFirst()
?.id
}
// The uri might be something like content://mms/inbox/id
// The box might change though, so we should just use the mms/id uri
val stableUri = when (type) {
"mms" -> ContentUris.withAppendedId(Telephony.Mms.CONTENT_URI, id)
else -> ContentUris.withAppendedId(Telephony.Sms.CONTENT_URI, id)
}
return contentResolver.query(stableUri, null, null, null, null)?.use { cursor ->
// If there are no rows, return null. Otherwise, we've moved to the first row
if (!cursor.moveToFirst()) return null
val columnsMap = CursorToMessage.MessageColumns(cursor)
cursorToMessage.map(Pair(cursor, columnsMap)).apply {
existingId?.let { this.id = it }
conversationRepo.getOrCreateConversation(threadId)
insertOrUpdate()
}
}
}
override fun syncContacts() {
// Load all the contacts
var contacts = getContacts()
Realm.getDefaultInstance()?.use { realm ->
val recipients = realm.where(Recipient::class.java).findAll()
realm.executeTransaction {
realm.delete(Contact::class.java)
contacts = realm.copyToRealm(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) }
}
}
}
realm.insertOrUpdate(updatedRecipients)
}
}
}
private fun getContacts(): List<Contact> {
return cursorToContact.getContactsCursor()
?.map { cursor -> cursorToContact.map(cursor) }
?.groupBy { contact -> contact.lookupKey }
?.map { contacts ->
val allNumbers = contacts.value.map { it.numbers }.flatten()
contacts.value.first().apply {
numbers.clear()
numbers.addAll(allNumbers)
}
} ?: listOf()
}
}
@@ -0,0 +1,53 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.service
import android.app.IntentService
import android.content.Intent
import android.net.Uri
import android.telephony.TelephonyManager
import com.moez.QKSMS.interactor.SendMessage
import com.moez.QKSMS.repository.ConversationRepository
import dagger.android.AndroidInjection
import javax.inject.Inject
class HeadlessSmsSendService : IntentService("HeadlessSmsSendService") {
@Inject lateinit var conversationRepo: ConversationRepository
@Inject lateinit var sendMessage: SendMessage
override fun onHandleIntent(intent: Intent?) {
if (intent?.action != TelephonyManager.ACTION_RESPOND_VIA_MESSAGE) return
AndroidInjection.inject(this)
intent.extras?.getString(Intent.EXTRA_TEXT)?.takeIf { it.isNotBlank() }?.let { body ->
val intentUri = intent.data
val recipients = getRecipients(intentUri).split(";")
val threadId = conversationRepo.getOrCreateConversation(recipients)?.id ?: 0L
sendMessage.execute(SendMessage.Params(-1, threadId, recipients, body))
}
}
private fun getRecipients(uri: Uri): String {
val base = uri.schemeSpecificPart
val position = base.indexOf('?')
return if (position == -1) base else base.substring(0, position)
}
}
@@ -0,0 +1,43 @@
package com.moez.QKSMS.util
import com.moez.QKSMS.compat.SubscriptionInfoCompat
import com.moez.QKSMS.compat.SubscriptionManagerCompat
import io.reactivex.Observable
import io.reactivex.Observer
import io.reactivex.disposables.Disposable
class ActiveSubscriptionObservable(
private val subscriptionManager: SubscriptionManagerCompat
) : Observable<List<SubscriptionInfoCompat>>() {
override fun subscribeActual(observer: Observer<in List<SubscriptionInfoCompat>>) {
observer.onNext(subscriptionManager.activeSubscriptionInfoList)
val listener = Listener(subscriptionManager, observer)
observer.onSubscribe(listener)
subscriptionManager.addOnSubscriptionsChangedListener(listener)
}
internal class Listener(
private val subscriptionManager: SubscriptionManagerCompat,
private val observer: Observer<in List<SubscriptionInfoCompat>>
) : Disposable, SubscriptionManagerCompat.OnSubscriptionsChangedListener() {
private var disposed: Boolean = false
override fun onSubscriptionsChanged() {
if (!isDisposed) {
observer.onNext(subscriptionManager.activeSubscriptionInfoList)
}
}
override fun isDisposed(): Boolean = disposed
override fun dispose() {
disposed = true
subscriptionManager.removeOnSubscriptionsChangedListener(this)
}
}
}
@@ -0,0 +1,130 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.util
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import com.moez.QKSMS.receiver.NightModeReceiver
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NightModeManager @Inject constructor(
private val context: Context,
private val prefs: Preferences) {
fun updateCurrentTheme() {
// If night mode is not on auto, then there's nothing to do here
if (prefs.nightMode.get() != Preferences.NIGHT_MODE_AUTO) {
return
}
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
prefs.night.set(nightStartTime > nightEndTime)
}
fun updateNightMode(mode: Int) {
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)
}
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 {
val calendar = parseTime(time)
return Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, calendar.get(Calendar.HOUR_OF_DAY))
set(Calendar.MINUTE, calendar.get(Calendar.MINUTE))
}
}
/**
* Parses the hour and minute out of the [time], which should be formatted h:mm
*/
fun parseTime(time: String): Calendar {
return tryOrNull {
val parsedTime = SimpleDateFormat("H:mm", Locale.US).parse(time)
Calendar.getInstance().apply { this.time = parsedTime }
} ?: tryOrNull {
// Parse the legacy timestamp format (<=3.1.3)
val parsedTime = SimpleDateFormat("h:mm a", Locale.US).parse(time)
Calendar.getInstance().apply { this.time = parsedTime }
} ?: Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, 18) }
}
/**
* Returns a Calendar set to the most recent occurrence of this time
*/
private fun getPreviousInstanceOfTime(time: String): Calendar {
val currentTime = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10)
val calendar = createCalendar(time)
while (calendar.timeInMillis > currentTime) {
calendar.add(Calendar.DAY_OF_YEAR, -1)
}
return calendar
}
}
@@ -0,0 +1,111 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.util
import android.os.Build
import android.provider.Settings
import com.f2prateek.rx.preferences2.Preference
import com.f2prateek.rx.preferences2.RxSharedPreferences
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class Preferences @Inject constructor(private val rxPrefs: RxSharedPreferences) {
companion object {
const val NIGHT_MODE_OFF = 0
const val NIGHT_MODE_ON = 1
const val NIGHT_MODE_AUTO = 2
const val TEXT_SIZE_SMALL = 0
const val TEXT_SIZE_NORMAL = 1
const val TEXT_SIZE_LARGE = 2
const val TEXT_SIZE_LARGER = 3
const val NOTIFICATION_PREVIEWS_ALL = 0
const val NOTIFICATION_PREVIEWS_NAME = 1
const val NOTIFICATION_PREVIEWS_NONE = 2
const val SEND_DELAY_NONE = 0
const val SEND_DELAY_SHORT = 1
const val SEND_DELAY_MEDIUM = 2
const val SEND_DELAY_LONG = 3
}
val night = rxPrefs.getBoolean("night", false)
val nightMode = rxPrefs.getInteger("nightModeSummary", NIGHT_MODE_OFF)
val nightStart = rxPrefs.getString("nightStart", "18:00")
val nightEnd = rxPrefs.getString("nightEnd", "6:00")
val black = rxPrefs.getBoolean("black", false)
val sia = rxPrefs.getBoolean("sia", false)
val sendDelay = rxPrefs.getInteger("sendDelay", SEND_DELAY_NONE)
val autoEmoji = rxPrefs.getBoolean("autoEmoji", true)
val delivery = rxPrefs.getBoolean("delivery", false)
val systemFont = rxPrefs.getBoolean("systemFont", false)
val textSize = rxPrefs.getInteger("textSize", TEXT_SIZE_NORMAL)
val qkreply = rxPrefs.getBoolean("qkreply", Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
val qkreplyTapDismiss = rxPrefs.getBoolean("qkreplyTapDismiss", true)
val unicode = rxPrefs.getBoolean("unicode", false)
val mmsSize = rxPrefs.getInteger("mmsSize", 300)
fun theme(threadId: Long = 0): Preference<Int> {
val default = rxPrefs.getInteger("theme", 0xFF0097A7.toInt())
return when (threadId) {
0L -> default
else -> rxPrefs.getInteger("theme_$threadId", default.get())
}
}
fun notifications(threadId: Long = 0): Preference<Boolean> {
val default = rxPrefs.getBoolean("notifications", true)
return when (threadId) {
0L -> default
else -> rxPrefs.getBoolean("notifications_$threadId", default.get())
}
}
fun notificationPreviews(threadId: Long = 0): Preference<Int> {
val default = rxPrefs.getInteger("notification_previews", 0)
return when (threadId) {
0L -> default
else -> rxPrefs.getInteger("notification_previews_$threadId", default.get())
}
}
fun vibration(threadId: Long = 0): Preference<Boolean> {
val default = rxPrefs.getBoolean("vibration", true)
return when (threadId) {
0L -> default
else -> rxPrefs.getBoolean("vibration$threadId", default.get())
}
}
fun ringtone(threadId: Long = 0): Preference<String> {
val default = rxPrefs.getString("ringtone", Settings.System.DEFAULT_NOTIFICATION_URI.toString())
return when (threadId) {
0L -> default
else -> rxPrefs.getString("ringtone_$threadId", default.get())
}
}
}
@@ -0,0 +1,27 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.moez.QKSMS.util
fun <T> tryOrNull(body: () -> T?): T? {
return try {
body()
} catch (e: Exception) {
null
}
}