Rename package to org.fossify.messages

This commit is contained in:
Naveen 2024-01-18 01:05:03 +05:30
parent d71db351ca
commit e2f83f49da
No known key found for this signature in database
GPG key ID: 0E155DAD31671DA3
106 changed files with 417 additions and 418 deletions

View file

@ -0,0 +1,177 @@
package org.fossify.messages.helpers
import android.app.Activity
import android.net.Uri
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.R
import org.fossify.messages.databinding.ItemAttachmentDocumentBinding
import org.fossify.messages.databinding.ItemAttachmentDocumentPreviewBinding
import org.fossify.messages.databinding.ItemAttachmentVcardBinding
import org.fossify.messages.databinding.ItemAttachmentVcardPreviewBinding
import org.fossify.messages.extensions.*
fun ItemAttachmentDocumentPreviewBinding.setupDocumentPreview(
uri: Uri,
title: String,
mimeType: String,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
onRemoveButtonClicked: (() -> Unit)? = null
) {
documentAttachmentHolder.setupDocumentPreview(uri, title, mimeType, onClick, onLongClick)
removeAttachmentButtonHolder.removeAttachmentButton.apply {
beVisible()
background.applyColorFilter(context.getProperPrimaryColor())
if (onRemoveButtonClicked != null) {
setOnClickListener {
onRemoveButtonClicked.invoke()
}
}
}
}
fun ItemAttachmentDocumentBinding.setupDocumentPreview(
uri: Uri,
title: String,
mimeType: String,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null
) {
val context = root.context
if (title.isNotEmpty()) {
filename.text = title
}
ensureBackgroundThread {
try {
val size = context.getFileSizeFromUri(uri)
root.post {
fileSize.beVisible()
fileSize.text = size.formatSize()
}
} catch (e: Exception) {
root.post {
fileSize.beGone()
}
}
}
val textColor = context.getProperTextColor()
val primaryColor = context.getProperPrimaryColor()
filename.setTextColor(textColor)
fileSize.setTextColor(textColor)
icon.setImageResource(getIconResourceForMimeType(mimeType))
icon.background.setTint(primaryColor)
root.background.applyColorFilter(primaryColor.darkenColor())
root.setOnClickListener {
onClick?.invoke()
}
root.setOnLongClickListener {
onLongClick?.invoke()
true
}
}
fun ItemAttachmentVcardPreviewBinding.setupVCardPreview(
activity: Activity,
uri: Uri,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
onRemoveButtonClicked: (() -> Unit)? = null,
) {
vcardProgress.beVisible()
vcardAttachmentHolder.setupVCardPreview(activity = activity, uri = uri, attachment = true, onClick = onClick, onLongClick = onLongClick) {
vcardProgress.beGone()
removeAttachmentButtonHolder.removeAttachmentButton.apply {
beVisible()
background.applyColorFilter(activity.getProperPrimaryColor())
if (onRemoveButtonClicked != null) {
setOnClickListener {
onRemoveButtonClicked.invoke()
}
}
}
}
}
fun ItemAttachmentVcardBinding.setupVCardPreview(
activity: Activity,
uri: Uri,
attachment: Boolean = false,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
onVCardLoaded: (() -> Unit)? = null,
) {
val context = root.context
val textColor = activity.getProperTextColor()
val primaryColor = activity.getProperPrimaryColor()
root.background.applyColorFilter(primaryColor.darkenColor())
vcardTitle.setTextColor(textColor)
vcardSubtitle.setTextColor(textColor)
arrayOf(vcardPhoto, vcardTitle, vcardSubtitle, viewContactDetails).forEach {
it.beGone()
}
parseVCardFromUri(activity, uri) { vCards ->
activity.runOnUiThread {
if (vCards.isEmpty()) {
vcardTitle.beVisible()
vcardTitle.text = context.getString(org.fossify.commons.R.string.unknown_error_occurred)
return@runOnUiThread
}
val title = vCards.firstOrNull()?.parseNameFromVCard()
val imageIcon = if (title != null) {
SimpleContactsHelper(activity).getContactLetterIcon(title)
} else {
null
}
arrayOf(vcardPhoto, vcardTitle).forEach {
it.beVisible()
}
vcardPhoto.setImageBitmap(imageIcon)
vcardTitle.text = title
if (vCards.size > 1) {
vcardSubtitle.beVisible()
val quantity = vCards.size - 1
vcardSubtitle.text = context.resources.getQuantityString(R.plurals.and_other_contacts, quantity, quantity)
} else {
vcardSubtitle.beGone()
}
if (attachment) {
onVCardLoaded?.invoke()
} else {
viewContactDetails.setTextColor(primaryColor)
viewContactDetails.beVisible()
}
vcardAttachmentHolder.setOnClickListener {
onClick?.invoke()
}
vcardAttachmentHolder.setOnLongClickListener {
onLongClick?.invoke()
true
}
}
}
}
private fun getIconResourceForMimeType(mimeType: String) = when {
mimeType.isAudioMimeType() -> R.drawable.ic_vector_audio_file
mimeType.isCalendarMimeType() -> R.drawable.ic_calendar_month_vector
mimeType.isPdfMimeType() -> R.drawable.ic_vector_pdf
mimeType.isZipMimeType() -> R.drawable.ic_vector_folder_zip
else -> R.drawable.ic_document_vector
}

View file

@ -0,0 +1,74 @@
package org.fossify.messages.helpers
import android.util.Xml
import org.xmlpull.v1.XmlPullParser
object AttachmentUtils {
fun parseAttachmentNames(text: String): List<String> {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(text.reader())
parser.nextTag()
return readSmil(parser)
}
private fun readSmil(parser: XmlPullParser): List<String> {
parser.require(XmlPullParser.START_TAG, null, "smil")
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) {
continue
}
if (parser.name == "body") {
return readBody(parser)
} else {
skip(parser)
}
}
return emptyList()
}
private fun readBody(parser: XmlPullParser): List<String> {
val names = mutableListOf<String>()
parser.require(XmlPullParser.START_TAG, null, "body")
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) {
continue
}
if (parser.name == "par") {
parser.require(XmlPullParser.START_TAG, null, "par")
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.eventType != XmlPullParser.START_TAG) {
continue
}
if (parser.name == "ref") {
val value = parser.getAttributeValue(null, "src")
names.add(value)
parser.nextTag()
}
}
} else {
skip(parser)
}
}
return names
}
private fun skip(parser: XmlPullParser) {
if (parser.eventType != XmlPullParser.START_TAG) {
throw IllegalStateException()
}
var depth = 1
while (depth != 0) {
when (parser.next()) {
XmlPullParser.END_TAG -> depth--
XmlPullParser.START_TAG -> depth++
}
}
}
}

View file

@ -0,0 +1,130 @@
package org.fossify.messages.helpers
import android.content.Context
import org.fossify.commons.helpers.BaseConfig
import org.fossify.messages.extensions.getDefaultKeyboardHeight
import org.fossify.messages.models.Conversation
class Config(context: Context) : BaseConfig(context) {
companion object {
fun newInstance(context: Context) = Config(context)
}
fun saveUseSIMIdAtNumber(number: String, SIMId: Int) {
prefs.edit().putInt(USE_SIM_ID_PREFIX + number, SIMId).apply()
}
fun getUseSIMIdAtNumber(number: String) = prefs.getInt(USE_SIM_ID_PREFIX + number, 0)
var showCharacterCounter: Boolean
get() = prefs.getBoolean(SHOW_CHARACTER_COUNTER, false)
set(showCharacterCounter) = prefs.edit().putBoolean(SHOW_CHARACTER_COUNTER, showCharacterCounter).apply()
var useSimpleCharacters: Boolean
get() = prefs.getBoolean(USE_SIMPLE_CHARACTERS, false)
set(useSimpleCharacters) = prefs.edit().putBoolean(USE_SIMPLE_CHARACTERS, useSimpleCharacters).apply()
var sendOnEnter: Boolean
get() = prefs.getBoolean(SEND_ON_ENTER, false)
set(sendOnEnter) = prefs.edit().putBoolean(SEND_ON_ENTER, sendOnEnter).apply()
var enableDeliveryReports: Boolean
get() = prefs.getBoolean(ENABLE_DELIVERY_REPORTS, false)
set(enableDeliveryReports) = prefs.edit().putBoolean(ENABLE_DELIVERY_REPORTS, enableDeliveryReports).apply()
var sendLongMessageMMS: Boolean
get() = prefs.getBoolean(SEND_LONG_MESSAGE_MMS, false)
set(sendLongMessageMMS) = prefs.edit().putBoolean(SEND_LONG_MESSAGE_MMS, sendLongMessageMMS).apply()
var sendGroupMessageMMS: Boolean
get() = prefs.getBoolean(SEND_GROUP_MESSAGE_MMS, false)
set(sendGroupMessageMMS) = prefs.edit().putBoolean(SEND_GROUP_MESSAGE_MMS, sendGroupMessageMMS).apply()
var lockScreenVisibilitySetting: Int
get() = prefs.getInt(LOCK_SCREEN_VISIBILITY, LOCK_SCREEN_SENDER_MESSAGE)
set(lockScreenVisibilitySetting) = prefs.edit().putInt(LOCK_SCREEN_VISIBILITY, lockScreenVisibilitySetting).apply()
var mmsFileSizeLimit: Long
get() = prefs.getLong(MMS_FILE_SIZE_LIMIT, FILE_SIZE_600_KB)
set(mmsFileSizeLimit) = prefs.edit().putLong(MMS_FILE_SIZE_LIMIT, mmsFileSizeLimit).apply()
var pinnedConversations: Set<String>
get() = prefs.getStringSet(PINNED_CONVERSATIONS, HashSet<String>())!!
set(pinnedConversations) = prefs.edit().putStringSet(PINNED_CONVERSATIONS, pinnedConversations).apply()
fun addPinnedConversationByThreadId(threadId: Long) {
pinnedConversations = pinnedConversations.plus(threadId.toString())
}
fun addPinnedConversations(conversations: List<Conversation>) {
pinnedConversations = pinnedConversations.plus(conversations.map { it.threadId.toString() })
}
fun removePinnedConversationByThreadId(threadId: Long) {
pinnedConversations = pinnedConversations.minus(threadId.toString())
}
fun removePinnedConversations(conversations: List<Conversation>) {
pinnedConversations = pinnedConversations.minus(conversations.map { it.threadId.toString() })
}
var blockedKeywords: Set<String>
get() = prefs.getStringSet(BLOCKED_KEYWORDS, HashSet<String>())!!
set(blockedKeywords) = prefs.edit().putStringSet(BLOCKED_KEYWORDS, blockedKeywords).apply()
fun addBlockedKeyword(keyword: String) {
blockedKeywords = blockedKeywords.plus(keyword)
}
fun removeBlockedKeyword(keyword: String) {
blockedKeywords = blockedKeywords.minus(keyword)
}
var exportSms: Boolean
get() = prefs.getBoolean(EXPORT_SMS, true)
set(exportSms) = prefs.edit().putBoolean(EXPORT_SMS, exportSms).apply()
var exportMms: Boolean
get() = prefs.getBoolean(EXPORT_MMS, true)
set(exportMms) = prefs.edit().putBoolean(EXPORT_MMS, exportMms).apply()
var importSms: Boolean
get() = prefs.getBoolean(IMPORT_SMS, true)
set(importSms) = prefs.edit().putBoolean(IMPORT_SMS, importSms).apply()
var importMms: Boolean
get() = prefs.getBoolean(IMPORT_MMS, true)
set(importMms) = prefs.edit().putBoolean(IMPORT_MMS, importMms).apply()
var wasDbCleared: Boolean
get() = prefs.getBoolean(WAS_DB_CLEARED, false)
set(wasDbCleared) = prefs.edit().putBoolean(WAS_DB_CLEARED, wasDbCleared).apply()
var keyboardHeight: Int
get() = prefs.getInt(SOFT_KEYBOARD_HEIGHT, context.getDefaultKeyboardHeight())
set(keyboardHeight) = prefs.edit().putInt(SOFT_KEYBOARD_HEIGHT, keyboardHeight).apply()
var useRecycleBin: Boolean
get() = prefs.getBoolean(USE_RECYCLE_BIN, false)
set(useRecycleBin) = prefs.edit().putBoolean(USE_RECYCLE_BIN, useRecycleBin).apply()
var lastRecycleBinCheck: Long
get() = prefs.getLong(LAST_RECYCLE_BIN_CHECK, 0L)
set(lastRecycleBinCheck) = prefs.edit().putLong(LAST_RECYCLE_BIN_CHECK, lastRecycleBinCheck).apply()
var isArchiveAvailable: Boolean
get() = prefs.getBoolean(IS_ARCHIVE_AVAILABLE, true)
set(isArchiveAvailable) = prefs.edit().putBoolean(IS_ARCHIVE_AVAILABLE, isArchiveAvailable).apply()
var customNotifications: Set<String>
get() = prefs.getStringSet(CUSTOM_NOTIFICATIONS, HashSet<String>())!!
set(customNotifications) = prefs.edit().putStringSet(CUSTOM_NOTIFICATIONS, customNotifications).apply()
fun addCustomNotificationsByThreadId(threadId: Long) {
customNotifications = customNotifications.plus(threadId.toString())
}
fun removeCustomNotificationsByThreadId(threadId: Long) {
customNotifications = customNotifications.minus(threadId.toString())
}
}

View file

@ -0,0 +1,101 @@
package org.fossify.messages.helpers
import org.fossify.messages.models.Events
import org.greenrobot.eventbus.EventBus
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import kotlin.math.abs
import kotlin.random.Random
const val THREAD_ID = "thread_id"
const val THREAD_TITLE = "thread_title"
const val THREAD_TEXT = "thread_text"
const val THREAD_NUMBER = "thread_number"
const val THREAD_ATTACHMENT_URI = "thread_attachment_uri"
const val THREAD_ATTACHMENT_URIS = "thread_attachment_uris"
const val SEARCHED_MESSAGE_ID = "searched_message_id"
const val USE_SIM_ID_PREFIX = "use_sim_id_"
const val NOTIFICATION_CHANNEL = "simple_sms_messenger"
const val SHOW_CHARACTER_COUNTER = "show_character_counter"
const val USE_SIMPLE_CHARACTERS = "use_simple_characters"
const val SEND_ON_ENTER = "send_on_enter"
const val LOCK_SCREEN_VISIBILITY = "lock_screen_visibility"
const val ENABLE_DELIVERY_REPORTS = "enable_delivery_reports"
const val SEND_LONG_MESSAGE_MMS = "send_long_message_mms"
const val SEND_GROUP_MESSAGE_MMS = "send_group_message_mms"
const val MMS_FILE_SIZE_LIMIT = "mms_file_size_limit"
const val PINNED_CONVERSATIONS = "pinned_conversations"
const val BLOCKED_KEYWORDS = "blocked_keywords"
const val EXPORT_SMS = "export_sms"
const val EXPORT_MMS = "export_mms"
const val JSON_FILE_EXTENSION = ".json"
const val JSON_MIME_TYPE = "application/json"
const val XML_MIME_TYPE = "text/xml"
const val TXT_MIME_TYPE = "text/plain"
const val IMPORT_SMS = "import_sms"
const val IMPORT_MMS = "import_mms"
const val WAS_DB_CLEARED = "was_db_cleared_4"
const val EXTRA_VCARD_URI = "vcard"
const val SCHEDULED_MESSAGE_ID = "scheduled_message_id"
const val SOFT_KEYBOARD_HEIGHT = "soft_keyboard_height"
const val IS_MMS = "is_mms"
const val MESSAGE_ID = "message_id"
const val USE_RECYCLE_BIN = "use_recycle_bin"
const val LAST_RECYCLE_BIN_CHECK = "last_recycle_bin_check"
const val IS_RECYCLE_BIN = "is_recycle_bin"
const val IS_ARCHIVE_AVAILABLE = "is_archive_available"
const val CUSTOM_NOTIFICATIONS = "custom_notifications"
private const val PATH = "org.fossify.org.fossify.messages.action."
const val MARK_AS_READ = PATH + "mark_as_read"
const val REPLY = PATH + "reply"
// view types for the thread list view
const val THREAD_DATE_TIME = 1
const val THREAD_RECEIVED_MESSAGE = 2
const val THREAD_SENT_MESSAGE = 3
const val THREAD_SENT_MESSAGE_ERROR = 4
const val THREAD_SENT_MESSAGE_SENT = 5
const val THREAD_SENT_MESSAGE_SENDING = 6
const val THREAD_LOADING = 7
// view types for attachment list
const val ATTACHMENT_DOCUMENT = 7
const val ATTACHMENT_MEDIA = 8
const val ATTACHMENT_VCARD = 9
// lock screen visibility constants
const val LOCK_SCREEN_SENDER_MESSAGE = 1
const val LOCK_SCREEN_SENDER = 2
const val LOCK_SCREEN_NOTHING = 3
const val FILE_SIZE_NONE = -1L
const val FILE_SIZE_100_KB = 102_400L
const val FILE_SIZE_200_KB = 204_800L
const val FILE_SIZE_300_KB = 307_200L
const val FILE_SIZE_600_KB = 614_400L
const val FILE_SIZE_1_MB = 1_048_576L
const val FILE_SIZE_2_MB = 2_097_152L
const val MESSAGES_LIMIT = 30
// intent launch request codes
const val PICK_PHOTO_INTENT = 42
const val PICK_VIDEO_INTENT = 49
const val PICK_SAVE_FILE_INTENT = 43
const val CAPTURE_PHOTO_INTENT = 44
const val CAPTURE_VIDEO_INTENT = 45
const val CAPTURE_AUDIO_INTENT = 46
const val PICK_DOCUMENT_INTENT = 47
const val PICK_CONTACT_INTENT = 48
fun refreshMessages() {
EventBus.getDefault().post(Events.RefreshMessages())
}
/** Not to be used with real messages persisted in the telephony db. This is for internal use only (e.g. scheduled messages, notification ids etc). */
fun generateRandomId(length: Int = 9): Long {
val millis = DateTime.now(DateTimeZone.UTC).millis
val random = abs(Random(millis).nextLong())
return random.toString().takeLast(length).toLong()
}

View file

@ -0,0 +1,33 @@
package org.fossify.messages.helpers
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import org.fossify.commons.models.SimpleContact
import org.fossify.messages.models.Attachment
import org.fossify.messages.models.MessageAttachment
class Converters {
private val gson = Gson()
private val attachmentType = object : TypeToken<List<Attachment>>() {}.type
private val simpleContactType = object : TypeToken<List<SimpleContact>>() {}.type
private val messageAttachmentType = object : TypeToken<MessageAttachment?>() {}.type
@TypeConverter
fun jsonToAttachmentList(value: String) = gson.fromJson<ArrayList<Attachment>>(value, attachmentType)
@TypeConverter
fun attachmentListToJson(list: ArrayList<Attachment>) = gson.toJson(list)
@TypeConverter
fun jsonToSimpleContactList(value: String) = gson.fromJson<ArrayList<SimpleContact>>(value, simpleContactType)
@TypeConverter
fun simpleContactListToJson(list: ArrayList<SimpleContact>) = gson.toJson(list)
@TypeConverter
fun jsonToMessageAttachment(value: String) = gson.fromJson<MessageAttachment>(value, messageAttachmentType)
@TypeConverter
fun messageAttachmentToJson(messageAttachment: MessageAttachment?) = gson.toJson(messageAttachment)
}

View file

@ -0,0 +1,168 @@
package org.fossify.messages.helpers
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.ExifInterface
import android.net.Uri
import org.fossify.commons.extensions.getCompressionFormat
import org.fossify.commons.extensions.getMyFileUri
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.extensions.extension
import org.fossify.messages.extensions.getExtensionFromMimeType
import org.fossify.messages.extensions.getFileSizeFromUri
import org.fossify.messages.extensions.isImageMimeType
import java.io.File
import java.io.FileOutputStream
import kotlin.math.roundToInt
/**
* Compress image to a given size based on
* [Compressor](https://github.com/zetbaitsu/Compressor/)
* */
class ImageCompressor(private val context: Context) {
private val contentResolver = context.contentResolver
private val outputDirectory = File(context.cacheDir, "compressed").apply {
if (!exists()) {
mkdirs()
}
}
private val minQuality = 30
private val minResolution = 56
private val scaleStepFactor = 0.6f // increase for more accurate file size at the cost increased computation
fun compressImage(uri: Uri, compressSize: Long, lossy: Boolean = compressSize < FILE_SIZE_1_MB, callback: (compressedFileUri: Uri?) -> Unit) {
ensureBackgroundThread {
try {
val fileSize = context.getFileSizeFromUri(uri)
if (fileSize > compressSize) {
val mimeType = contentResolver.getType(uri)!!
if (mimeType.isImageMimeType()) {
val byteArray = contentResolver.openInputStream(uri)?.readBytes()!!
var imageFile = File(outputDirectory, System.currentTimeMillis().toString().plus(mimeType.getExtensionFromMimeType()))
imageFile.writeBytes(byteArray)
val bitmap = loadBitmap(imageFile)
val format = if (lossy) {
Bitmap.CompressFormat.JPEG
} else {
imageFile.path.getCompressionFormat()
}
// This quality approximation mostly works for smaller images but will fail with larger images.
val compressionRatio = compressSize / fileSize.toDouble()
val quality = maxOf((compressionRatio * 100).roundToInt(), minQuality)
imageFile = overWrite(imageFile, bitmap, format = format, quality = quality)
// Even the highest quality images start to look ugly if we use 10 as the minimum quality,
// so we better save some image quality and change resolution instead. This is time consuming
// and mostly needed for very large images. Since there's no reliable way to predict the
// required resolution, we'll just iterate and find the best result.
if (imageFile.length() > compressSize) {
var scaledWidth = bitmap.width
var scaledHeight = bitmap.height
while (imageFile.length() > compressSize) {
scaledWidth = (scaledWidth * scaleStepFactor).roundToInt()
scaledHeight = (scaledHeight * scaleStepFactor).roundToInt()
if (scaledHeight < minResolution && scaledWidth < minResolution) {
break
}
imageFile = decodeSampledBitmapFromFile(imageFile, scaledWidth, scaledHeight).run {
determineImageRotation(imageFile, bitmap = this).run {
overWrite(imageFile, bitmap = this, format = format, quality = quality)
}
}
}
}
callback.invoke(context.getMyFileUri(imageFile))
} else {
callback.invoke(null)
}
} else {
// no need to compress since the file is less than the compress size
callback.invoke(uri)
}
} catch (e: Exception) {
callback.invoke(null)
}
}
}
private fun overWrite(imageFile: File, bitmap: Bitmap, format: Bitmap.CompressFormat = imageFile.path.getCompressionFormat(), quality: Int = 100): File {
val result = if (format == imageFile.path.getCompressionFormat()) {
imageFile
} else {
File("${imageFile.absolutePath.substringBeforeLast(".")}.${format.extension()}")
}
imageFile.delete()
saveBitmap(bitmap, result, format, quality)
return result
}
private fun saveBitmap(bitmap: Bitmap, destination: File, format: Bitmap.CompressFormat = destination.path.getCompressionFormat(), quality: Int = 100) {
destination.parentFile?.mkdirs()
var fileOutputStream: FileOutputStream? = null
try {
fileOutputStream = FileOutputStream(destination.absolutePath)
bitmap.compress(format, quality, fileOutputStream)
} finally {
fileOutputStream?.run {
flush()
close()
}
}
}
private fun loadBitmap(imageFile: File) = BitmapFactory.decodeFile(imageFile.absolutePath).run {
determineImageRotation(imageFile, this)
}
private fun determineImageRotation(imageFile: File, bitmap: Bitmap): Bitmap {
val exif = ExifInterface(imageFile.absolutePath)
val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0)
val matrix = Matrix()
when (orientation) {
6 -> matrix.postRotate(90f)
3 -> matrix.postRotate(180f)
8 -> matrix.postRotate(270f)
}
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
private fun decodeSampledBitmapFromFile(imageFile: File, reqWidth: Int, reqHeight: Int): Bitmap {
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeFile(imageFile.absolutePath, this)
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
inJustDecodeBounds = false
BitmapFactory.decodeFile(imageFile.absolutePath, this)
}
}
private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
// Raw height and width of image
val height = options.outHeight
val width = options.outWidth
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
// height and width larger than the requested height and width.
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
}

View file

@ -0,0 +1,197 @@
package org.fossify.messages.helpers
import android.net.Uri
import android.util.Xml
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.fossify.commons.extensions.showErrorToast
import org.fossify.commons.extensions.toast
import org.fossify.commons.helpers.ensureBackgroundThread
import org.fossify.messages.activities.SimpleActivity
import org.fossify.messages.dialogs.ImportMessagesDialog
import org.fossify.messages.extensions.config
import org.fossify.messages.models.*
import org.xmlpull.v1.XmlPullParser
import java.io.InputStream
class MessagesImporter(private val activity: SimpleActivity) {
private val messageWriter = MessagesWriter(activity)
private val config = activity.config
private var messagesImported = 0
private var messagesFailed = 0
fun importMessages(uri: Uri) {
try {
val fileType = activity.contentResolver.getType(uri).orEmpty()
val isXml = isXmlMimeType(fileType) || (uri.path?.endsWith("txt") == true && isFileXml(uri))
if (isXml) {
activity.toast(org.fossify.commons.R.string.importing)
getInputStreamFromUri(uri)!!.importXml()
} else {
importJson(uri)
}
} catch (e: Exception) {
activity.showErrorToast(e)
}
}
private fun importJson(uri: Uri) {
try {
val jsonString = activity.contentResolver.openInputStream(uri)!!.use { inputStream ->
inputStream.bufferedReader().readText()
}
val deserializedList = Json.decodeFromString<List<MessagesBackup>>(jsonString)
if (deserializedList.isEmpty()) {
activity.toast(org.fossify.commons.R.string.no_entries_for_importing)
return
}
ImportMessagesDialog(activity, deserializedList)
} catch (e: SerializationException) {
activity.toast(org.fossify.commons.R.string.invalid_file_format)
} catch (e: IllegalArgumentException) {
activity.toast(org.fossify.commons.R.string.invalid_file_format)
} catch (e: Exception) {
activity.showErrorToast(e)
}
}
fun restoreMessages(messagesBackup: List<MessagesBackup>, callback: (ImportResult) -> Unit) {
ensureBackgroundThread {
try {
messagesBackup.forEach { message ->
try {
if (message.backupType == BackupType.SMS && config.importSms) {
messageWriter.writeSmsMessage(message as SmsBackup)
messagesImported++
} else if (message.backupType == BackupType.MMS && config.importMms) {
messageWriter.writeMmsMessage(message as MmsBackup)
messagesImported++
}
} catch (e: Exception) {
activity.showErrorToast(e)
messagesFailed++
}
}
refreshMessages()
} catch (e: Exception) {
activity.showErrorToast(e)
}
callback.invoke(
when {
messagesImported == 0 && messagesFailed == 0 -> ImportResult.IMPORT_NOTHING_NEW
messagesFailed > 0 && messagesImported > 0 -> ImportResult.IMPORT_PARTIAL
messagesFailed > 0 -> ImportResult.IMPORT_FAIL
else -> ImportResult.IMPORT_OK
}
)
}
}
private fun InputStream.importXml() {
try {
bufferedReader().use { reader ->
val xmlParser = Xml.newPullParser().apply {
setInput(reader)
}
xmlParser.nextTag()
xmlParser.require(XmlPullParser.START_TAG, null, "smses")
var depth = 1
while (depth != 0) {
when (xmlParser.next()) {
XmlPullParser.END_TAG -> depth--
XmlPullParser.START_TAG -> depth++
}
if (xmlParser.eventType != XmlPullParser.START_TAG) {
continue
}
try {
if (xmlParser.name == "sms") {
if (config.importSms) {
val message = xmlParser.readSms()
messageWriter.writeSmsMessage(message)
messagesImported++
} else {
xmlParser.skip()
}
} else {
xmlParser.skip()
}
} catch (e: Exception) {
activity.showErrorToast(e)
messagesFailed++
}
}
refreshMessages()
}
when {
messagesFailed > 0 && messagesImported > 0 -> activity.toast(org.fossify.commons.R.string.importing_some_entries_failed)
messagesFailed > 0 -> activity.toast(org.fossify.commons.R.string.importing_failed)
else -> activity.toast(org.fossify.commons.R.string.importing_successful)
}
} catch (_: Exception) {
activity.toast(org.fossify.commons.R.string.invalid_file_format)
}
}
private fun XmlPullParser.readSms(): SmsBackup {
require(XmlPullParser.START_TAG, null, "sms")
return SmsBackup(
subscriptionId = 0,
address = getAttributeValue(null, "address"),
body = getAttributeValue(null, "body"),
date = getAttributeValue(null, "date").toLong(),
dateSent = getAttributeValue(null, "date").toLong(),
locked = getAttributeValue(null, "locked").toInt(),
protocol = getAttributeValue(null, "protocol"),
read = getAttributeValue(null, "read").toInt(),
status = getAttributeValue(null, "status").toInt(),
type = getAttributeValue(null, "type").toInt(),
serviceCenter = getAttributeValue(null, "service_center")
)
}
private fun XmlPullParser.skip() {
if (eventType != XmlPullParser.START_TAG) {
throw IllegalStateException()
}
var depth = 1
while (depth != 0) {
when (next()) {
XmlPullParser.END_TAG -> depth--
XmlPullParser.START_TAG -> depth++
}
}
}
private fun getInputStreamFromUri(uri: Uri): InputStream? {
return try {
activity.contentResolver.openInputStream(uri)
} catch (e: Exception) {
null
}
}
private fun isFileXml(uri: Uri): Boolean {
val inputStream = getInputStreamFromUri(uri)
return inputStream?.bufferedReader()?.use { reader ->
reader.readLine()?.startsWith("<?xml") ?: false
} ?: false
}
private fun isXmlMimeType(mimeType: String): Boolean {
return mimeType.equals("application/xml", ignoreCase = true) || mimeType.equals("text/xml", ignoreCase = true)
}
private fun isJsonMimeType(mimeType: String): Boolean {
return mimeType.equals("application/json", ignoreCase = true)
}
}

View file

@ -0,0 +1,256 @@
package org.fossify.messages.helpers
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.provider.Telephony.Mms
import android.provider.Telephony.Sms
import android.util.Base64
import org.fossify.commons.extensions.*
import org.fossify.commons.helpers.isQPlus
import org.fossify.commons.helpers.isRPlus
import org.fossify.messages.extensions.getConversationIds
import org.fossify.messages.models.*
import java.io.IOException
import java.io.InputStream
class MessagesReader(private val context: Context) {
fun getMessagesToExport(
getSms: Boolean, getMms: Boolean, callback: (messages: List<MessagesBackup>) -> Unit
) {
val conversationIds = context.getConversationIds()
var smsMessages = listOf<SmsBackup>()
var mmsMessages = listOf<MmsBackup>()
if (getSms) {
smsMessages = getSmsMessages(conversationIds)
}
if (getMms) {
mmsMessages = getMmsMessages(conversationIds)
}
callback(smsMessages + mmsMessages)
}
private fun getSmsMessages(threadIds: List<Long>): List<SmsBackup> {
val projection = arrayOf(
Sms.SUBSCRIPTION_ID,
Sms.ADDRESS,
Sms.BODY,
Sms.DATE,
Sms.DATE_SENT,
Sms.LOCKED,
Sms.PROTOCOL,
Sms.READ,
Sms.STATUS,
Sms.TYPE,
Sms.SERVICE_CENTER
)
val selection = "${Sms.THREAD_ID} = ?"
val smsList = mutableListOf<SmsBackup>()
threadIds.map { it.toString() }.forEach { threadId ->
context.queryCursor(Sms.CONTENT_URI, projection, selection, arrayOf(threadId)) { cursor ->
val subscriptionId = cursor.getLongValue(Sms.SUBSCRIPTION_ID)
val address = cursor.getStringValue(Sms.ADDRESS)
val body = cursor.getStringValueOrNull(Sms.BODY)
val date = cursor.getLongValue(Sms.DATE)
val dateSent = cursor.getLongValue(Sms.DATE_SENT)
val locked = cursor.getIntValue(Sms.DATE_SENT)
val protocol = cursor.getStringValueOrNull(Sms.PROTOCOL)
val read = cursor.getIntValue(Sms.READ)
val status = cursor.getIntValue(Sms.STATUS)
val type = cursor.getIntValue(Sms.TYPE)
val serviceCenter = cursor.getStringValueOrNull(Sms.SERVICE_CENTER)
smsList.add(SmsBackup(subscriptionId, address, body, date, dateSent, locked, protocol, read, status, type, serviceCenter))
}
}
return smsList
}
private fun getMmsMessages(threadIds: List<Long>, includeTextOnlyAttachment: Boolean = false): List<MmsBackup> {
val projection = arrayOf(
Mms._ID,
Mms.CREATOR,
Mms.CONTENT_TYPE,
Mms.DELIVERY_REPORT,
Mms.DATE,
Mms.DATE_SENT,
Mms.LOCKED,
Mms.MESSAGE_TYPE,
Mms.MESSAGE_BOX,
Mms.READ,
Mms.READ_REPORT,
Mms.SEEN,
Mms.TEXT_ONLY,
Mms.STATUS,
Mms.SUBJECT_CHARSET,
Mms.SUBSCRIPTION_ID,
Mms.TRANSACTION_ID
)
val selection = if (includeTextOnlyAttachment) {
"${Mms.THREAD_ID} = ? AND ${Mms.TEXT_ONLY} = ?"
} else {
"${Mms.THREAD_ID} = ?"
}
val mmsList = mutableListOf<MmsBackup>()
threadIds.map { it.toString() }.forEach { threadId ->
val selectionArgs = if (includeTextOnlyAttachment) {
arrayOf(threadId, "1")
} else {
arrayOf(threadId)
}
context.queryCursor(Mms.CONTENT_URI, projection, selection, selectionArgs) { cursor ->
val mmsId = cursor.getLongValue(Mms._ID)
val creator = cursor.getStringValueOrNull(Mms.CREATOR)
val contentType = cursor.getStringValueOrNull(Mms.CONTENT_TYPE)
val deliveryReport = cursor.getIntValue(Mms.DELIVERY_REPORT)
val date = cursor.getLongValue(Mms.DATE)
val dateSent = cursor.getLongValue(Mms.DATE_SENT)
val locked = cursor.getIntValue(Mms.LOCKED)
val messageType = cursor.getIntValue(Mms.MESSAGE_TYPE)
val messageBox = cursor.getIntValue(Mms.MESSAGE_BOX)
val read = cursor.getIntValue(Mms.READ)
val readReport = cursor.getIntValue(Mms.READ_REPORT)
val seen = cursor.getIntValue(Mms.SEEN)
val textOnly = cursor.getIntValue(Mms.TEXT_ONLY)
val status = cursor.getStringValueOrNull(Mms.STATUS)
val subject = cursor.getStringValueOrNull(Mms.SUBJECT)
val subjectCharSet = cursor.getStringValueOrNull(Mms.SUBJECT_CHARSET)
val subscriptionId = cursor.getLongValue(Mms.SUBSCRIPTION_ID)
val transactionId = cursor.getStringValueOrNull(Mms.TRANSACTION_ID)
val parts = getParts(mmsId)
val addresses = getMmsAddresses(mmsId)
mmsList.add(
MmsBackup(
creator,
contentType,
deliveryReport,
date,
dateSent,
locked,
messageType,
messageBox,
read,
readReport,
seen,
textOnly,
status,
subject,
subjectCharSet,
subscriptionId,
transactionId,
addresses,
parts
)
)
}
}
return mmsList
}
@SuppressLint("NewApi")
private fun getParts(mmsId: Long): List<MmsPart> {
val parts = mutableListOf<MmsPart>()
val uri = if (isQPlus()) Mms.Part.CONTENT_URI else Uri.parse("content://mms/part")
val projection = arrayOf(
Mms.Part._ID,
Mms.Part.CONTENT_DISPOSITION,
Mms.Part.CHARSET,
Mms.Part.CONTENT_ID,
Mms.Part.CONTENT_LOCATION,
Mms.Part.CONTENT_TYPE,
Mms.Part.CT_START,
Mms.Part.CT_TYPE,
Mms.Part.FILENAME,
Mms.Part.NAME,
Mms.Part.SEQ,
Mms.Part.TEXT
)
val selection = "${Mms.Part.MSG_ID} = ?"
val selectionArgs = arrayOf(mmsId.toString())
context.queryCursor(uri, projection, selection, selectionArgs) { cursor ->
val partId = cursor.getLongValue(Mms.Part._ID)
val contentDisposition = cursor.getStringValueOrNull(Mms.Part.CONTENT_DISPOSITION)
val charset = cursor.getStringValueOrNull(Mms.Part.CHARSET)
val contentId = cursor.getStringValueOrNull(Mms.Part.CONTENT_ID)
val contentLocation = cursor.getStringValueOrNull(Mms.Part.CONTENT_LOCATION)
val contentType = cursor.getStringValue(Mms.Part.CONTENT_TYPE)
val ctStart = cursor.getStringValueOrNull(Mms.Part.CT_START)
val ctType = cursor.getStringValueOrNull(Mms.Part.CT_TYPE)
val filename = cursor.getStringValueOrNull(Mms.Part.FILENAME)
val name = cursor.getStringValueOrNull(Mms.Part.NAME)
val sequenceOrder = cursor.getIntValue(Mms.Part.SEQ)
val text = cursor.getStringValueOrNull(Mms.Part.TEXT)
val data = when {
contentType.startsWith("text/") -> {
usePart(partId) { stream ->
stream.readBytes().toString(Charsets.UTF_8)
}
}
else -> {
usePart(partId) { stream ->
Base64.encodeToString(stream.readBytes(), Base64.DEFAULT)
}
}
}
parts.add(MmsPart(contentDisposition, charset, contentId, contentLocation, contentType, ctStart, ctType, filename, name, sequenceOrder, text, data))
}
return parts
}
@SuppressLint("NewApi")
private fun usePart(partId: Long, block: (InputStream) -> String): String {
val partUri = if (isQPlus()) Mms.Part.CONTENT_URI.buildUpon().appendPath(partId.toString()).build() else Uri.parse("content://mms/part/$partId")
try {
val stream = context.contentResolver.openInputStream(partUri) ?: return ""
stream.use {
return block(stream)
}
} catch (e: IOException) {
return ""
}
}
@SuppressLint("NewApi")
private fun getMmsAddresses(messageId: Long): List<MmsAddress> {
val addresses = mutableListOf<MmsAddress>()
val uri = if (isRPlus()) Mms.Addr.getAddrUriForMessage(messageId.toString()) else Uri.parse("content://mms/$messageId/addr")
val projection = arrayOf(Mms.Addr.ADDRESS, Mms.Addr.TYPE, Mms.Addr.CHARSET)
val selection = "${Mms.Addr.MSG_ID}= ?"
val selectionArgs = arrayOf(messageId.toString())
context.queryCursor(uri, projection, selection, selectionArgs) { cursor ->
val address = cursor.getStringValue(Mms.Addr.ADDRESS)
val type = cursor.getIntValue(Mms.Addr.TYPE)
val charset = cursor.getIntValue(Mms.Addr.CHARSET)
addresses.add(MmsAddress(address, type, charset))
}
return addresses
}
fun getMessagesCount(): Int {
return getSmsCount() + getMmsCount()
}
fun getMmsCount(): Int {
return countRows(Mms.CONTENT_URI)
}
fun getSmsCount(): Int {
return countRows(Sms.CONTENT_URI)
}
private fun countRows(uri: Uri): Int {
val cursor = context.contentResolver.query(
uri, null, null, null, null
) ?: return 0
cursor.use {
return cursor.count
}
}
}

View file

@ -0,0 +1,155 @@
package org.fossify.messages.helpers
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.provider.Telephony.Mms
import android.provider.Telephony.Sms
import android.util.Base64
import com.google.android.mms.pdu_alt.PduHeaders
import com.klinker.android.send_message.Utils
import org.fossify.commons.extensions.getLongValue
import org.fossify.commons.extensions.queryCursor
import org.fossify.commons.helpers.isRPlus
import org.fossify.messages.models.MmsAddress
import org.fossify.messages.models.MmsBackup
import org.fossify.messages.models.MmsPart
import org.fossify.messages.models.SmsBackup
class MessagesWriter(private val context: Context) {
private val INVALID_ID = -1L
private val contentResolver = context.contentResolver
fun writeSmsMessage(smsBackup: SmsBackup) {
val contentValues = smsBackup.toContentValues()
val threadId = Utils.getOrCreateThreadId(context, smsBackup.address)
contentValues.put(Sms.THREAD_ID, threadId)
if (!smsExist(smsBackup)) {
contentResolver.insert(Sms.CONTENT_URI, contentValues)
}
}
private fun smsExist(smsBackup: SmsBackup): Boolean {
val uri = Sms.CONTENT_URI
val projection = arrayOf(Sms._ID)
val selection = "${Sms.DATE} = ? AND ${Sms.ADDRESS} = ? AND ${Sms.TYPE} = ?"
val selectionArgs = arrayOf(smsBackup.date.toString(), smsBackup.address, smsBackup.type.toString())
var exists = false
context.queryCursor(uri, projection, selection, selectionArgs) {
exists = it.count > 0
}
return exists
}
fun writeMmsMessage(mmsBackup: MmsBackup) {
// 1. write mms msg, get the msg_id, check if mms exists before writing
// 2. write parts - parts depend on the msg id, check if part exist before writing, write data if it is a non-text part
// 3. write the addresses, address depends on msg id too, check if address exist before writing
val contentValues = mmsBackup.toContentValues()
val threadId = getMmsThreadId(mmsBackup)
if (threadId != INVALID_ID) {
contentValues.put(Mms.THREAD_ID, threadId)
if (!mmsExist(mmsBackup)) {
contentResolver.insert(Mms.CONTENT_URI, contentValues)
}
val messageId = getMmsId(mmsBackup)
if (messageId != INVALID_ID) {
mmsBackup.parts.forEach { writeMmsPart(it, messageId) }
mmsBackup.addresses.forEach { writeMmsAddress(it, messageId) }
}
}
}
private fun getMmsThreadId(mmsBackup: MmsBackup): Long {
val address = when (mmsBackup.messageBox) {
Mms.MESSAGE_BOX_INBOX -> mmsBackup.addresses.firstOrNull { it.type == PduHeaders.FROM }?.address
else -> mmsBackup.addresses.firstOrNull { it.type == PduHeaders.TO }?.address
}
return if (!address.isNullOrEmpty()) {
Utils.getOrCreateThreadId(context, address)
} else {
INVALID_ID
}
}
private fun getMmsId(mmsBackup: MmsBackup): Long {
val threadId = getMmsThreadId(mmsBackup)
val uri = Mms.CONTENT_URI
val projection = arrayOf(Mms._ID)
val selection = "${Mms.DATE} = ? AND ${Mms.DATE_SENT} = ? AND ${Mms.THREAD_ID} = ? AND ${Mms.MESSAGE_BOX} = ?"
val selectionArgs = arrayOf(mmsBackup.date.toString(), mmsBackup.dateSent.toString(), threadId.toString(), mmsBackup.messageBox.toString())
var id = INVALID_ID
context.queryCursor(uri, projection, selection, selectionArgs) {
id = it.getLongValue(Mms._ID)
}
return id
}
private fun mmsExist(mmsBackup: MmsBackup): Boolean {
return getMmsId(mmsBackup) != INVALID_ID
}
@SuppressLint("NewApi")
private fun mmsAddressExist(mmsAddress: MmsAddress, messageId: Long): Boolean {
val addressUri = if (isRPlus()) Mms.Addr.getAddrUriForMessage(messageId.toString()) else Uri.parse("content://mms/$messageId/addr")
val projection = arrayOf(Mms.Addr._ID)
val selection = "${Mms.Addr.TYPE} = ? AND ${Mms.Addr.ADDRESS} = ? AND ${Mms.Addr.MSG_ID} = ?"
val selectionArgs = arrayOf(mmsAddress.type.toString(), mmsAddress.address.toString(), messageId.toString())
var exists = false
context.queryCursor(addressUri, projection, selection, selectionArgs) {
exists = it.count > 0
}
return exists
}
@SuppressLint("NewApi")
private fun writeMmsAddress(mmsAddress: MmsAddress, messageId: Long) {
if (!mmsAddressExist(mmsAddress, messageId)) {
val addressUri = if (isRPlus()) {
Mms.Addr.getAddrUriForMessage(messageId.toString())
} else {
Uri.parse("content://mms/$messageId/addr")
}
val contentValues = mmsAddress.toContentValues()
contentValues.put(Mms.Addr.MSG_ID, messageId)
contentResolver.insert(addressUri, contentValues)
}
}
@SuppressLint("NewApi")
private fun writeMmsPart(mmsPart: MmsPart, messageId: Long) {
if (!mmsPartExist(mmsPart, messageId)) {
val uri = Uri.parse("content://mms/${messageId}/part")
val contentValues = mmsPart.toContentValues()
contentValues.put(Mms.Part.MSG_ID, messageId)
val partUri = contentResolver.insert(uri, contentValues)
try {
if (partUri != null) {
if (mmsPart.isNonText()) {
contentResolver.openOutputStream(partUri).use {
val arr = Base64.decode(mmsPart.data, Base64.DEFAULT)
it!!.write(arr)
}
}
}
} catch (e: Exception) {
}
}
}
@SuppressLint("NewApi")
private fun mmsPartExist(mmsPart: MmsPart, messageId: Long): Boolean {
val uri = Uri.parse("content://mms/${messageId}/part")
val projection = arrayOf(Mms.Part._ID)
val selection = "${Mms.Part.CONTENT_LOCATION} = ? AND ${Mms.Part.CONTENT_TYPE} = ? AND ${Mms.Part.MSG_ID} = ? AND ${Mms.Part.CONTENT_ID} = ?"
val selectionArgs = arrayOf(mmsPart.contentLocation.toString(), mmsPart.contentType, messageId.toString(), mmsPart.contentId.toString())
var exists = false
context.queryCursor(uri, projection, selection, selectionArgs) {
exists = it.count > 0
}
return exists
}
}

View file

@ -0,0 +1,230 @@
package org.fossify.messages.helpers
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager.IMPORTANCE_HIGH
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import org.fossify.commons.extensions.getProperPrimaryColor
import org.fossify.commons.extensions.notificationManager
import org.fossify.commons.helpers.SimpleContactsHelper
import org.fossify.commons.helpers.isNougatPlus
import org.fossify.commons.helpers.isOreoPlus
import org.fossify.messages.R
import org.fossify.messages.activities.ThreadActivity
import org.fossify.messages.extensions.config
import org.fossify.messages.messaging.isShortCodeWithLetters
import org.fossify.messages.receivers.DeleteSmsReceiver
import org.fossify.messages.receivers.DirectReplyReceiver
import org.fossify.messages.receivers.MarkAsReadReceiver
class NotificationHelper(private val context: Context) {
private val notificationManager = context.notificationManager
private val soundUri get() = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
private val user = Person.Builder()
.setName(context.getString(R.string.me))
.build()
@SuppressLint("NewApi")
fun showMessageNotification(
messageId: Long,
address: String,
body: String,
threadId: Long,
bitmap: Bitmap?,
sender: String?,
alertOnlyOnce: Boolean = false
) {
val hasCustomNotifications = context.config.customNotifications.contains(threadId.toString())
val notificationChannelId = if (hasCustomNotifications) threadId.toString() else NOTIFICATION_CHANNEL
if (!hasCustomNotifications) {
maybeCreateChannel(notificationChannelId, context.getString(R.string.channel_received_sms))
}
val notificationId = threadId.hashCode()
val contentIntent = Intent(context, ThreadActivity::class.java).apply {
putExtra(THREAD_ID, threadId)
}
val contentPendingIntent =
PendingIntent.getActivity(context, notificationId, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
val markAsReadIntent = Intent(context, MarkAsReadReceiver::class.java).apply {
action = MARK_AS_READ
putExtra(THREAD_ID, threadId)
}
val markAsReadPendingIntent =
PendingIntent.getBroadcast(context, notificationId, markAsReadIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
val deleteSmsIntent = Intent(context, DeleteSmsReceiver::class.java).apply {
putExtra(THREAD_ID, threadId)
putExtra(MESSAGE_ID, messageId)
}
val deleteSmsPendingIntent =
PendingIntent.getBroadcast(context, notificationId, deleteSmsIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
var replyAction: NotificationCompat.Action? = null
val isNoReplySms = isShortCodeWithLetters(address)
if (isNougatPlus() && !isNoReplySms) {
val replyLabel = context.getString(R.string.reply)
val remoteInput = RemoteInput.Builder(REPLY)
.setLabel(replyLabel)
.build()
val replyIntent = Intent(context, DirectReplyReceiver::class.java).apply {
putExtra(THREAD_ID, threadId)
putExtra(THREAD_NUMBER, address)
}
val replyPendingIntent =
PendingIntent.getBroadcast(
context.applicationContext,
notificationId,
replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
replyAction = NotificationCompat.Action.Builder(R.drawable.ic_send_vector, replyLabel, replyPendingIntent)
.addRemoteInput(remoteInput)
.build()
}
val largeIcon = bitmap ?: if (sender != null) {
SimpleContactsHelper(context).getContactLetterIcon(sender)
} else {
null
}
val builder = NotificationCompat.Builder(context, notificationChannelId).apply {
when (context.config.lockScreenVisibilitySetting) {
LOCK_SCREEN_SENDER_MESSAGE -> {
setLargeIcon(largeIcon)
setStyle(getMessagesStyle(address, body, notificationId, sender))
}
LOCK_SCREEN_SENDER -> {
setContentTitle(sender)
setLargeIcon(largeIcon)
val summaryText = context.getString(R.string.new_message)
setStyle(NotificationCompat.BigTextStyle().setSummaryText(summaryText).bigText(body))
}
}
color = context.getProperPrimaryColor()
setSmallIcon(R.drawable.ic_messenger)
setContentIntent(contentPendingIntent)
priority = NotificationCompat.PRIORITY_MAX
setDefaults(Notification.DEFAULT_LIGHTS)
setCategory(Notification.CATEGORY_MESSAGE)
setAutoCancel(true)
setOnlyAlertOnce(alertOnlyOnce)
setSound(soundUri, AudioManager.STREAM_NOTIFICATION)
}
if (replyAction != null && context.config.lockScreenVisibilitySetting == LOCK_SCREEN_SENDER_MESSAGE) {
builder.addAction(replyAction)
}
builder.addAction(org.fossify.commons.R.drawable.ic_check_vector, context.getString(R.string.mark_as_read), markAsReadPendingIntent)
.setChannelId(notificationChannelId)
if (isNoReplySms) {
builder.addAction(
org.fossify.commons.R.drawable.ic_delete_vector,
context.getString(org.fossify.commons.R.string.delete),
deleteSmsPendingIntent
).setChannelId(notificationChannelId)
}
notificationManager.notify(notificationId, builder.build())
}
@SuppressLint("NewApi")
fun showSendingFailedNotification(recipientName: String, threadId: Long) {
val hasCustomNotifications = context.config.customNotifications.contains(threadId.toString())
val notificationChannelId = if (hasCustomNotifications) threadId.toString() else NOTIFICATION_CHANNEL
if (!hasCustomNotifications) {
maybeCreateChannel(notificationChannelId, context.getString(R.string.message_not_sent_short))
}
val notificationId = generateRandomId().hashCode()
val intent = Intent(context, ThreadActivity::class.java).apply {
putExtra(THREAD_ID, threadId)
}
val contentPendingIntent = PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
val summaryText = String.format(context.getString(R.string.message_sending_error), recipientName)
val largeIcon = SimpleContactsHelper(context).getContactLetterIcon(recipientName)
val builder = NotificationCompat.Builder(context, notificationChannelId)
.setContentTitle(context.getString(R.string.message_not_sent_short))
.setContentText(summaryText)
.setColor(context.getProperPrimaryColor())
.setSmallIcon(R.drawable.ic_messenger)
.setLargeIcon(largeIcon)
.setStyle(NotificationCompat.BigTextStyle().bigText(summaryText))
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setDefaults(Notification.DEFAULT_LIGHTS)
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setChannelId(notificationChannelId)
notificationManager.notify(notificationId, builder.build())
}
private fun maybeCreateChannel(id: String, name: String) {
if (isOreoPlus()) {
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setLegacyStreamType(AudioManager.STREAM_NOTIFICATION)
.build()
val importance = IMPORTANCE_HIGH
NotificationChannel(id, name, importance).apply {
setBypassDnd(false)
enableLights(true)
setSound(soundUri, audioAttributes)
enableVibration(true)
notificationManager.createNotificationChannel(this)
}
}
}
private fun getMessagesStyle(address: String, body: String, notificationId: Int, name: String?): NotificationCompat.MessagingStyle {
val sender = if (name != null) {
Person.Builder()
.setName(name)
.setKey(address)
.build()
} else {
null
}
return NotificationCompat.MessagingStyle(user).also { style ->
getOldMessages(notificationId).forEach {
style.addMessage(it)
}
val newMessage = NotificationCompat.MessagingStyle.Message(body, System.currentTimeMillis(), sender)
style.addMessage(newMessage)
}
}
private fun getOldMessages(notificationId: Int): List<NotificationCompat.MessagingStyle.Message> {
if (!isNougatPlus()) {
return emptyList()
}
val currentNotification = notificationManager.activeNotifications.find { it.id == notificationId }
return if (currentNotification != null) {
val activeStyle = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(currentNotification.notification)
activeStyle?.messages.orEmpty()
} else {
emptyList()
}
}
}

View file

@ -0,0 +1,37 @@
package org.fossify.messages.helpers
import android.content.Context
import android.net.Uri
import ezvcard.Ezvcard
import ezvcard.VCard
import org.fossify.commons.helpers.ensureBackgroundThread
fun parseVCardFromUri(context: Context, uri: Uri, callback: (vCards: List<VCard>) -> Unit) {
ensureBackgroundThread {
val inputStream = try {
context.contentResolver.openInputStream(uri)
} catch (e: Exception) {
callback(emptyList())
return@ensureBackgroundThread
}
val vCards = Ezvcard.parse(inputStream).all()
callback(vCards)
}
}
fun VCard?.parseNameFromVCard(): String? {
if (this == null) return null
var fullName = formattedName?.value
if (fullName.isNullOrEmpty()) {
val structured = structuredName ?: return null
val nameComponents = arrayListOf<String?>().apply {
addAll(structured.prefixes)
add(structured.given)
addAll(structured.additionalNames)
add(structured.family)
addAll(structured.suffixes)
}
fullName = nameComponents.filter { !it.isNullOrEmpty() }.joinToString(separator = " ")
}
return fullName
}