diff --git a/src/org/kde/kdeconnect/Helpers/SMSHelper.java b/src/org/kde/kdeconnect/Helpers/SMSHelper.java index 7e9ff6f0..bdcb560e 100644 --- a/src/org/kde/kdeconnect/Helpers/SMSHelper.java +++ b/src/org/kde/kdeconnect/Helpers/SMSHelper.java @@ -26,6 +26,9 @@ import android.content.Context; import android.database.ContentObserver; import android.database.Cursor; import android.database.sqlite.SQLiteException; +import android.graphics.Bitmap; +import android.media.MediaMetadataRetriever; +import android.media.ThumbnailUtils; import android.net.Uri; import android.os.Build; import android.os.Looper; @@ -46,6 +49,8 @@ import org.apache.commons.lang3.math.NumberUtils; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; +import org.kde.kdeconnect.Plugins.SMSPlugin.MimeType; +import org.kde.kdeconnect.Plugins.SMSPlugin.SmsMmsUtils; import java.io.IOException; import java.io.InputStream; @@ -56,7 +61,6 @@ import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -71,6 +75,9 @@ import kotlin.text.Charsets; @SuppressLint("InlinedApi") public class SMSHelper { + private static final int THUMBNAIL_HEIGHT = 100; + private static final int THUMBNAIL_WIDTH = 100; + /** * Get a URI for querying SMS messages */ @@ -86,7 +93,7 @@ public class SMSHelper { return Telephony.Mms.CONTENT_URI; } - private static Uri getMMSPartUri() { + public static Uri getMMSPartUri() { // Android says we should have Telephony.Mms.Part.CONTENT_URI. Alas, we do not. return Uri.parse("content://mms/part/"); } @@ -606,7 +613,8 @@ public class SMSHelper { threadID, uID, event, - subscriptionID + subscriptionID, + null ); } @@ -628,6 +636,7 @@ public class SMSHelper { @NonNull ThreadID threadID = new ThreadID(Long.parseLong(messageInfo.get(Message.THREAD_ID))); long uID = Long.parseLong(messageInfo.get(Message.U_ID)); int subscriptionID = NumberUtils.toInt(messageInfo.get(Message.SUBSCRIPTION_ID)); + List attachments = new ArrayList<>(); String[] columns = { Telephony.Mms.Part._ID, // The content ID of this part @@ -660,7 +669,7 @@ public class SMSHelper { long partID = cursor.getLong(partIDColumn); String contentType = cursor.getString(contentTypeColumn); String data = cursor.getString(dataColumn); - if ("text/plain".equals(contentType)) { + if (MimeType.isTypeText(contentType)) { if (data != null) { // data != null means the data is on disk. Go get it. body = getMmsText(context, partID); @@ -668,10 +677,41 @@ public class SMSHelper { body = cursor.getString(textColumn); } event = addEventFlag(event, Message.EVENT_TEXT_MESSAGE); - } //TODO: Parse more content types (photos and other attachments) here + } else if (MimeType.isTypeImage(contentType)) { + String mimeType = contentType; + String fileName = data.substring(data.lastIndexOf('/') + 1); + // Get the actual image from the mms database convert it into thumbnail and encode to Base64 + Bitmap image = SmsMmsUtils.getMmsImage(context, partID); + Bitmap thumbnailImage = ThumbnailUtils.extractThumbnail(image, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); + String encodedThumbnail = SmsMmsUtils.bitMapToBase64(thumbnailImage); + + attachments.add(new Attachment(partID, mimeType, encodedThumbnail, fileName)); + } else if (MimeType.isTypeVideo(contentType)) { + String mimeType = contentType; + String fileName = data.substring(data.lastIndexOf('/') + 1); + + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + retriever.setDataSource(context, ContentUris.withAppendedId(getMMSPartUri(), partID)); + Bitmap videoThumbnail = retriever.getFrameAtTime(); + + String encodedThumbnail = SmsMmsUtils.bitMapToBase64( + Bitmap.createScaledBitmap(videoThumbnail, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, true) + ); + + attachments.add(new Attachment(partID, mimeType, encodedThumbnail, fileName)); + } else if (MimeType.isTypeAudio(contentType)) { + String mimeType = contentType; + String fileName = data.substring(data.lastIndexOf('/') + 1); + + attachments.add(new Attachment(partID, mimeType, null, fileName)); + } else { + Log.v("SMSHelper", "Unsupported attachment type: " + contentType); + } } while (cursor.moveToNext()); } + } catch (Exception e) { + e.printStackTrace(); } // Determine whether the message was in- our out- bound @@ -689,7 +729,26 @@ public class SMSHelper { } // Get address(es) of the message - List
addresses = getMmsAddresses(context, Long.parseLong(mmsID), userPhoneNumbers); + Uri uri = ContentUris.appendId(getMMSUri().buildUpon(), uID).build(); + Address from = SmsMmsUtils.getMmsFrom(context, uri); + + List
to = SmsMmsUtils.getMmsTo(context, uri); + + List
addresses = new ArrayList<>(); + if (from != null) { + if (!userPhoneNumbers.contains(from.toString()) && !from.toString().equals("insert-address-token")) { + addresses.add(from); + } + } + + if (to != null) { + for (Address address : to) { + if (!userPhoneNumbers.contains(address.toString()) && !address.toString().equals("insert-address-token")) { + addresses.add(address); + } + } + } + // It looks like addresses[0] is always the sender of the message and // following addresses are recipient(s) // This usually means the addresses list is at least 2 long, but there are cases (special @@ -713,78 +772,11 @@ public class SMSHelper { threadID, uID, event, - subscriptionID + subscriptionID, + attachments ); } - /** - * Get the address(es) of an MMS message - * Original implementation from https://stackoverflow.com/a/6446831/3723163 - * - * The message at the first position of the list should be the sender of the message - * - * @param messageID ID of this message in the MMS database for looking up the remaining info - * @param userPhoneNumbers List of phone numbers which should be removed from the list of addresses - */ - private static @NonNull List
getMmsAddresses( - @NonNull Context context, - @NonNull Long messageID, - @NonNull List userPhoneNumbers - ) { - Uri uri = ContentUris.appendId(getMMSUri().buildUpon(), messageID).appendPath("addr").build(); - - String[] columns = { - Telephony.Mms.Addr.MSG_ID, // ID of the message for which we are fetching addresses - Telephony.Mms.Addr.ADDRESS, // Address of this part - Telephony.Mms.Addr.CHARSET, // Charset of the returned address (where relevant) //TODO: Handle - }; - - String selection = Telephony.Mms.Addr.MSG_ID + " = ?"; - String[] selectionArgs = {messageID.toString()}; - - // Keep an ordered set rather than a list because Android sometimes throws duplicates at us - Set
addresses = new LinkedHashSet<>(); - - try (Cursor addrCursor = context.getContentResolver().query( - uri, - columns, - selection, - selectionArgs, - null - )) { - if (addrCursor != null && addrCursor.moveToFirst()) { - int addressIndex = addrCursor.getColumnIndex(Telephony.Mms.Addr.ADDRESS); - - do { - String address = addrCursor.getString(addressIndex); - addresses.add(new Address(address)); - } while (addrCursor.moveToNext()); - } - } - - // Prune the user's phone numbers from the list of addresses - List
prunedAddresses = new ArrayList<>(addresses); - prunedAddresses.removeAll(userPhoneNumbers); - - if (prunedAddresses.size() == 0) { - // If it turns out that we have pruned away everything, prune away nothing - // (The user is allowed to talk to themself) - - // Remove duplicate entries, since the user knows if a conversation says "Me" on it, - // it is the conversation with themself. (We don't need to say "Me, Me") - // This leaves the multi-sim case alone, so the returned address list might say - // "Me1, Me2" - - prunedAddresses = new ArrayList<>(addresses.size()); // The old one was empty too, but just to be clear... - for (Address address : addresses) { - if (!prunedAddresses.contains(address)) { - prunedAddresses.add(address); - } - } - } - return prunedAddresses; - } - /** * Get a text part of an MMS message * Original implementation from https://stackoverflow.com/a/6446831/3723163 @@ -846,6 +838,46 @@ public class SMSHelper { } } + public static class Attachment { + final long partID; + final String mimeType; + final String base64EncodedFile; + final String uniqueIdentifier; + + /** + * Attachment object field names + */ + public static final String PART_ID = "part_id"; + public static final String MIME_TYPE = "mime_type"; + public static final String ENCODED_THUMBNAIL = "encoded_thumbnail"; + public static final String UNIQUE_IDENTIFIER = "unique_identifier"; + + public Attachment(long partID, + String mimeType, + @Nullable String base64EncodedFile, + String uniqueIdentifier + ) { + this.partID = partID; + this.mimeType = mimeType; + this.base64EncodedFile = base64EncodedFile; + this.uniqueIdentifier = uniqueIdentifier; + } + + public JSONObject toJson() throws JSONException { + JSONObject json = new JSONObject(); + + json.put(Attachment.PART_ID, this.partID); + json.put(Attachment.MIME_TYPE, this.mimeType); + + if (this.base64EncodedFile != null) { + json.put(Attachment.ENCODED_THUMBNAIL, this.base64EncodedFile); + } + json.put(Attachment.UNIQUE_IDENTIFIER, this.uniqueIdentifier); + + return json; + } + } + public static class Address { final String address; @@ -905,7 +937,6 @@ public class SMSHelper { JSONObject jsonObject = jsonArray.getJSONObject(i); String address = jsonObject.getString("address"); addresses.add(new Address(address)); - Log.e("address", address); } } catch (Exception e) { e.printStackTrace(); @@ -951,6 +982,7 @@ public class SMSHelper { public final long uID; public final int event; public final int subscriptionID; + public final List attachments; /** * Named constants which are used to construct a Message @@ -965,6 +997,7 @@ public class SMSHelper { static final String U_ID = Telephony.Sms._ID; // Something which uniquely identifies this message static final String EVENT = "event"; static final String SUBSCRIPTION_ID = Telephony.Sms.SUBSCRIPTION_ID; // An ID which appears to identify a SIM card + static final String ATTACHMENTS = "attachments"; // List of files attached in an MMS /** * Event flags @@ -1016,7 +1049,8 @@ public class SMSHelper { @NonNull ThreadID threadID, long uID, int event, - int subscriptionID + int subscriptionID, + @Nullable List attachments ) { this.addresses = addresses; this.body = body; @@ -1035,6 +1069,7 @@ public class SMSHelper { this.uID = uID; this.subscriptionID = subscriptionID; this.event = event; + this.attachments = attachments; } public JSONObject toJSONObject() throws JSONException { @@ -1055,6 +1090,14 @@ public class SMSHelper { json.put(Message.SUBSCRIPTION_ID, subscriptionID); json.put(Message.EVENT, event); + if (this.attachments != null) { + JSONArray jsonAttachments = new JSONArray(); + for (Attachment attachment : this.attachments) { + jsonAttachments.put(attachment.toJson()); + } + json.put(Message.ATTACHMENTS, jsonAttachments); + } + return json; } diff --git a/src/org/kde/kdeconnect/Plugins/SMSPlugin/MimeType.java b/src/org/kde/kdeconnect/Plugins/SMSPlugin/MimeType.java new file mode 100644 index 00000000..6d506093 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/SMSPlugin/MimeType.java @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Aniket Kumar + * + * This program 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 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * This program 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 this program. If not, see . + */ + +package org.kde.kdeconnect.Plugins.SMSPlugin; + +public final class MimeType { + + public static final String TYPE_TEXT = "text/plain"; + public static final String TYPE_IMAGE = "image"; + public static final String TYPE_VIDEO = "video"; + public static final String TYPE_AUDIO = "audio"; + + public static boolean isTypeText(String mimeType) { return mimeType.startsWith(TYPE_TEXT); } + + public static boolean isTypeImage(String mimeType) { + return mimeType.startsWith(TYPE_IMAGE); + } + + public static boolean isTypeVideo(String mimeType) { return mimeType.startsWith(TYPE_VIDEO); } + + public static boolean isTypeAudio(String mimeType) { return mimeType.startsWith(TYPE_AUDIO); } + + public static String postfixOf(String mimeType) { return mimeType.substring(mimeType.lastIndexOf('/')+1); } +} + diff --git a/src/org/kde/kdeconnect/Plugins/SMSPlugin/NotificationReplyReceiver.java b/src/org/kde/kdeconnect/Plugins/SMSPlugin/NotificationReplyReceiver.java index 966d2175..45ec5c34 100644 --- a/src/org/kde/kdeconnect/Plugins/SMSPlugin/NotificationReplyReceiver.java +++ b/src/org/kde/kdeconnect/Plugins/SMSPlugin/NotificationReplyReceiver.java @@ -300,4 +300,4 @@ public class NotificationReplyReceiver extends BroadcastReceiver { return markAsReadPendingIntent; } -} +} \ No newline at end of file diff --git a/src/org/kde/kdeconnect/Plugins/SMSPlugin/SMSPlugin.java b/src/org/kde/kdeconnect/Plugins/SMSPlugin/SMSPlugin.java index 71daecaa..395f66b9 100644 --- a/src/org/kde/kdeconnect/Plugins/SMSPlugin/SMSPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/SMSPlugin/SMSPlugin.java @@ -113,6 +113,16 @@ public class SMSPlugin extends Plugin { * // If this value is not defined or if it does not match a valid subscriber_id known by * // Android, we will use whatever subscriber ID Android gives us as the default * + * "attachments": > // List of Attachment objects, one for each attached file in the message. + * + * An Attachment object looks like: + * { + * "part_id": // part_id of the attachment used to read the file from MMS database + * "mime_type": // contains the mime type of the file (image, video, audio, etc.) + * "encoded_thumbnail": // Optional base64-encoded thumbnail preview of the content for types which support it + * "unique_identifier": // Unique name of te file + * } + * * An Address object looks like: * { * "address": // Address (phone number, email address, etc.) of this object diff --git a/src/org/kde/kdeconnect/Plugins/SMSPlugin/SmsMmsUtils.java b/src/org/kde/kdeconnect/Plugins/SMSPlugin/SmsMmsUtils.java index badff29b..6f67ffb8 100644 --- a/src/org/kde/kdeconnect/Plugins/SMSPlugin/SmsMmsUtils.java +++ b/src/org/kde/kdeconnect/Plugins/SMSPlugin/SmsMmsUtils.java @@ -26,6 +26,10 @@ import android.content.SharedPreferences; import android.os.Build; import android.preference.PreferenceManager; +import com.google.android.mms.pdu_alt.EncodedStringValue; +import com.google.android.mms.pdu_alt.MultimediaMessagePdu; +import com.google.android.mms.pdu_alt.PduPersister; +import com.google.android.mms.pdu_alt.RetrieveConf; import com.klinker.android.send_message.Message; import com.klinker.android.send_message.MmsSentReceiver; import com.klinker.android.send_message.Settings; @@ -34,8 +38,11 @@ import com.klinker.android.send_message.Utils; import android.content.ContentUris; import android.content.ContentValues; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.provider.Telephony; import android.net.Uri; +import android.util.Base64; import android.util.Log; import androidx.annotation.RequiresApi; @@ -45,6 +52,9 @@ import org.kde.kdeconnect.Helpers.SMSHelper; import org.kde.kdeconnect.Helpers.TelephonyHelper; import org.kde.kdeconnect_tp.R; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -129,6 +139,123 @@ public class SmsMmsUtils { } } + /** + * Returns the Address of the sender of the MMS message. + * @param uri content://mms/msgId/addr + * @param context context in which the method is called. + * @return sender's Address + */ + public static SMSHelper.Address getMmsFrom(Context context, Uri uri) { + MultimediaMessagePdu msg; + + try { + msg = (MultimediaMessagePdu) PduPersister.getPduPersister(context).load(uri); + } catch (Exception e) { + return null; + } + + EncodedStringValue encodedStringValue = msg.getFrom(); + SMSHelper.Address from = new SMSHelper.Address(encodedStringValue.getString()); + return from; + } + + /** + * returns a List of Addresses of all the recipients of a MMS message. + * @param uri content://mms/part_id + * @param context Context in which the method is called. + * @return List of Addresses of all recipients of an MMS message + */ + public static List getMmsTo(Context context, Uri uri) { + MultimediaMessagePdu msg; + + try { + msg = (MultimediaMessagePdu) PduPersister.getPduPersister(context).load(uri); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + + StringBuilder toBuilder = new StringBuilder(); + EncodedStringValue to[] = msg.getTo(); + + if (to != null) { + toBuilder.append(EncodedStringValue.concat(to)); + } + + if (msg instanceof RetrieveConf) { + EncodedStringValue cc[] = ((RetrieveConf) msg).getCc(); + if (cc != null && cc.length == 0) { + toBuilder.append(";"); + toBuilder.append(EncodedStringValue.concat(cc)); + } + } + + String built = toBuilder.toString().replace(";", ", "); + if (built.startsWith(", ")) { + built = built.substring(2); + } + + return stripDuplicatePhoneNumbers(built); + } + + /** + * Removes duplicate addresses from the string and returns List of Addresses + */ + public static List stripDuplicatePhoneNumbers(String phoneNumbers) { + if (phoneNumbers == null) { + return null; + } + + String numbers[] = phoneNumbers.split(", "); + + List uniqueNumbers = new ArrayList<>(); + + for (String number : numbers) { + if (!uniqueNumbers.contains(number.trim())) { + uniqueNumbers.add(new SMSHelper.Address(number.trim())); + } + } + + return uniqueNumbers; + } + + /** + * Converts a given bitmap to an encoded Base64 string for sending to desktop + * @param bitmap bitmap to be encoded into string* + * @return Returns the Base64 encoded string + */ + public static String bitMapToBase64(Bitmap bitmap) { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + + // The below line is not really compressing to PNG so much as encoding as PNG, since PNG is lossless + boolean isCompressed = bitmap.compress(Bitmap.CompressFormat.PNG,100, byteArrayOutputStream); + if (isCompressed) { + byte[] b = byteArrayOutputStream.toByteArray(); + String encodedString = Base64.encodeToString(b, Base64.DEFAULT); + return encodedString; + } + return null; + } + + /** + * Reads the image files attached with an MMS from MMS database + * @param context Context in which the method is called + * @param id part ID of the image file attached with an MMS message + * @return Returns the image as a bitmap + */ + public static Bitmap getMmsImage(Context context, long id) { + Uri partURI = ContentUris.withAppendedId(SMSHelper.getMMSPartUri(), id); + Bitmap bitmap = null; + + try (InputStream inputStream = context.getContentResolver().openInputStream(partURI)) { + bitmap = BitmapFactory.decodeStream(inputStream); + } catch (IOException e) { + Log.e("SmsMmsUtils", "Exception", e); + } + + return bitmap; + } + /** * Marks a conversation as read in the database. * diff --git a/src/org/kde/kdeconnect/Plugins/SMSPlugin/SmsReceiver.java b/src/org/kde/kdeconnect/Plugins/SMSPlugin/SmsReceiver.java index 57821175..921d929b 100644 --- a/src/org/kde/kdeconnect/Plugins/SMSPlugin/SmsReceiver.java +++ b/src/org/kde/kdeconnect/Plugins/SMSPlugin/SmsReceiver.java @@ -31,7 +31,6 @@ import android.telephony.SmsMessage; import android.provider.Telephony.Sms; import android.net.Uri; import android.content.ContentValues; -import android.util.Log; import androidx.core.app.NotificationCompat; import androidx.core.app.Person;