diff --git a/app/build.gradle b/app/build.gradle index a05374710..2abea382b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,6 +16,7 @@ android { versionCode 1602 versionName '1.6.0-BETA2' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + ndk.abiFilters 'armeabi-v7a','arm64-v8a' } signingConfigs { @@ -47,15 +48,15 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_6 - targetCompatibility JavaVersion.VERSION_1_6 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { implementation 'com.squareup:otto:1.3.8' implementation 'com.github.jberkel.k-9:k9mail-library:eaf689025e' - implementation 'com.android.billingclient:billing:2.0.3' + implementation 'com.android.billingclient:billing:2.1.0' implementation 'com.firebase:firebase-jobdispatcher:0.8.6' implementation 'androidx.annotation:annotation:1.1.0' implementation 'androidx.preference:preference:1.1.0' diff --git a/app/src/main/java/com/zegoggles/smssync/mail/HeaderGenerator.java b/app/src/main/java/com/zegoggles/smssync/mail/HeaderGenerator.java index 19e051c25..9cdd14b27 100644 --- a/app/src/main/java/com/zegoggles/smssync/mail/HeaderGenerator.java +++ b/app/src/main/java/com/zegoggles/smssync/mail/HeaderGenerator.java @@ -2,6 +2,8 @@ import android.provider.CallLog; import android.provider.Telephony; +import android.util.Log; + import androidx.annotation.NonNull; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.MessagingException; @@ -14,6 +16,7 @@ import java.util.Map; import java.util.TimeZone; +import static com.zegoggles.smssync.App.TAG; import static com.zegoggles.smssync.utils.Sanitizer.sanitize; /** @@ -36,13 +39,15 @@ public void setHeaders(final Message message, final Map msgMap, final DataType dataType, final String address, - final @NonNull PersonRecord contact, + final String referenceId, final Date sentDate, final int status) throws MessagingException { - // Threading by contact ID, not by thread ID. I think this value is more stable. - message.setHeader(Headers.REFERENCES, String.format(REFERENCE_UID_TEMPLATE, reference, contact.getId())); - message.setHeader(Headers.MESSAGE_ID, createMessageId(sentDate, address, status)); + message.setHeader(Headers.REFERENCES, String.format(REFERENCE_UID_TEMPLATE, reference, referenceId)); + // "v2" effectively versions how we hash each message. + // This goes along with a fix for how MMS messages are grouped, and allows users to + // reset the app state and re-backup their existing messages to fix older threads. + message.setHeader(Headers.MESSAGE_ID, createMessageId(sentDate, address + "v2", status)); message.setHeader(Headers.ADDRESS, sanitize(address)); message.setHeader(Headers.DATATYPE, dataType.toString()); message.setHeader(Headers.BACKUP_TIME, toGMTString(new Date())); diff --git a/app/src/main/java/com/zegoggles/smssync/mail/MessageGenerator.java b/app/src/main/java/com/zegoggles/smssync/mail/MessageGenerator.java index 5250a03ab..dc965196d 100644 --- a/app/src/main/java/com/zegoggles/smssync/mail/MessageGenerator.java +++ b/app/src/main/java/com/zegoggles/smssync/mail/MessageGenerator.java @@ -21,9 +21,14 @@ import com.zegoggles.smssync.preferences.CallLogTypes; import com.zegoggles.smssync.preferences.DataTypePreferences; +import java.util.ArrayList; +import java.util.Collections; import java.util.Date; +import java.util.HashSet; +import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import static com.fsck.k9.mail.internet.MimeMessageHelper.setBody; import static com.zegoggles.smssync.App.LOCAL_LOGV; @@ -106,7 +111,11 @@ class MessageGenerator { Log.e(TAG, ERROR_PARSING_DATE, n); sentDate = new Date(); } - headerGenerator.setHeaders(msg, msgMap, DataType.SMS, address, record, sentDate, messageType); + + // see mmsThreadId. Aligning with the MMS thread ID is necessary for a mixed SMS/MMS thread to stay combined in gmail. + String smsThreadId = msgMap.get(Telephony.BaseMmsColumns.THREAD_ID); + + headerGenerator.setHeaders(msg, msgMap, DataType.SMS, address, smsThreadId, sentDate, messageType); return msg; } @@ -114,25 +123,27 @@ class MessageGenerator { if (LOCAL_LOGV) Log.v(TAG, "messageFromMapMms(" + msgMap + ")"); final Uri mmsUri = Uri.withAppendedPath(Consts.MMS_PROVIDER, msgMap.get(Telephony.BaseMmsColumns._ID)); - MmsSupport.MmsDetails details = mmsSupport.getDetails(mmsUri, addressStyle); + + MmsSupport.MmsDetails details = mmsSupport.getDetails(mmsUri, addressStyle, msgMap); if (details.isEmpty()) { Log.w(TAG, "no recipients found"); return null; - } else if (!includeInBackup(DataType.MMS, details.records)) { + } else if (!includeInBackup(DataType.MMS, details.getRecipients())) { Log.w(TAG, "no recipients included"); return null; } final Message msg = new MimeMessage(); - msg.setSubject(getSubject(DataType.MMS, details.getRecipient())); + msg.setSubject(getSubject(DataType.MMS, details)); + if (details.inbound) { // msg_box == MmsConsts.MESSAGE_BOX_INBOX does not work - msg.setFrom(details.getRecipientAddress()); - msg.setRecipient(Message.RecipientType.TO, userAddress); + msg.setFrom(details.getSender().getAddress(addressStyle)); + msg.setRecipients(Message.RecipientType.TO, details.getRecipientAddresses(addressStyle)); // Includes everyone that received the MMS in the email "to" field. } else { - msg.setRecipients(Message.RecipientType.TO, details.getAddresses()); + msg.setRecipients(Message.RecipientType.TO, details.getRecipientAddresses(addressStyle)); msg.setFrom(userAddress); } @@ -144,7 +155,28 @@ class MessageGenerator { sentDate = new Date(); } final int msg_box = toInt(msgMap.get("msg_box")); - headerGenerator.setHeaders(msg, msgMap, DataType.MMS, details.address, details.getRecipient(), sentDate, msg_box); + + // We could thread by contact ID, not by thread ID. Original author thought this value was more stable. + // It works pretty badly with MMS threads though. +// String mmsThreadId = details.getRecipient(); + + // We could grab all the raw addresses (phone numbers), and sort/unique/join them for gmail's thread id + // This doesn't work well, because sometimes phone numbers have a "+1" prefix, sometimes not. +// Set rawAddresses = new HashSet<>(); +// rawAddresses.addAll(details.rawAddresses); +// List rawAddressesSorted = new ArrayList<>(rawAddresses.size()); +// rawAddressesSorted.addAll(rawAddresses); +// Collections.sort(rawAddressesSorted); +// String mmsThreadId = stringJoin(rawAddressesSorted, "-"); + + // We could thread by the messaging app's thread ID. The original author thought this wasn't very stable, + // but it's the best option for MMS so far, and it looks good from limited testing. + String mmsThreadId = msgMap.get(Telephony.BaseMmsColumns.THREAD_ID); + + // Tip - if you want to try different strategies for computing mmsThreadId against the same messages, + // make sure to generate a new MESSAGE_ID as well. It seems like gmail caches REFERENCES if you reuse the same MESSAGE_ID. + + headerGenerator.setHeaders(msg, msgMap, DataType.MMS, details.getFirstRawAddress(), mmsThreadId, sentDate, msg_box); MimeMultipart body = MimeMultipart.newInstance(); for (BodyPart p : mmsSupport.getMMSBodyParts(Uri.withAppendedPath(mmsUri, MMS_PART))) { @@ -200,10 +232,64 @@ class MessageGenerator { Log.e(TAG, ERROR_PARSING_DATE, n); sentDate = new Date(); } - headerGenerator.setHeaders(msg, msgMap, DataType.CALLLOG, address, record, sentDate, callType); + headerGenerator.setHeaders(msg, msgMap, DataType.CALLLOG, address, record.getId(), sentDate, callType); return msg; } + private String getSubject(@NonNull DataType type, @NonNull MmsSupport.MmsDetails details) { + // If you're in a group text with several people, ensure the email subject will look like + // "SMS with Alice/Bob/Charles/YourPhoneNumber" + + Set allNames = new HashSet<>(); // eliminate duplicates. There will be some - android's MMS apis are weird. + + // Try to eliminate the current user's number from this, so MMS subjects align with SMS subjects, so that they get a single thread in gmail. + // Only supported for MMS messages with 1 other person. + // For group texts (more than 2 people involved), it's not as important to remove current user, + // b/c the thread is entirely MMS, so it doesn't need to align with SMS. + // Of course it would be nice/cleaner to remove the current user's phone number from group MMS subjects too. + // But when we parse sender/recipients in MmsSupport.getDetails(), there's no apparent way to determine + // which recipient is the current user. So this avoids adding another mechanism to grab the current user's phone number(s). + if (details.getRecipients().size() == 1 && details.inbound) { + // We don't need to add the one recipient (the current user) + } else { + for (PersonRecord recipient : details.getRecipients()) { + allNames.add(recipient.getName()); + } + } + + if (details.getRecipients().size() == 1 && !details.inbound) { + // for outgoing MMS messages, we don't want to include the sender (aligns with SMS) + } else { + allNames.add(details.sender.getName()); + } + + + List allNamesSorted = new ArrayList<>(allNames.size()); + allNamesSorted.addAll(allNames); + Collections.sort(allNamesSorted); // keep subjects stable so gmail can keep threads consistent + + String namesJoinedBySlash = stringJoin(allNamesSorted, "/"); + + return prefix ? + String.format(Locale.ENGLISH, "[%s] %s", dataTypePreferences.getFolder(type), namesJoinedBySlash) : + context.getString(type.withField, namesJoinedBySlash); + } + + private String stringJoin(List allNamesSorted, String delimeter) { + StringBuilder builder = new StringBuilder(); + + boolean isFirst = true; + for (String name : allNamesSorted) { + if (!isFirst) { + builder.append(delimeter); + } + isFirst = false; + + builder.append(name); + } + return builder.toString(); + } + private String getSubject(@NonNull DataType type, @NonNull PersonRecord record) { return prefix ? String.format(Locale.ENGLISH, "[%s] %s", dataTypePreferences.getFolder(type), record.getName()) : diff --git a/app/src/main/java/com/zegoggles/smssync/mail/MmsSupport.java b/app/src/main/java/com/zegoggles/smssync/mail/MmsSupport.java index ba15af47e..96be5336f 100644 --- a/app/src/main/java/com/zegoggles/smssync/mail/MmsSupport.java +++ b/app/src/main/java/com/zegoggles/smssync/mail/MmsSupport.java @@ -3,9 +3,12 @@ import android.content.ContentResolver; import android.database.Cursor; import android.net.Uri; -import androidx.annotation.NonNull; +import android.provider.Telephony; import android.text.TextUtils; import android.util.Log; + +import androidx.annotation.NonNull; + import com.fsck.k9.mail.Address; import com.fsck.k9.mail.BodyPart; import com.fsck.k9.mail.MessagingException; @@ -16,9 +19,9 @@ import com.zegoggles.smssync.preferences.AddressStyle; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.Locale; +import java.util.Map; import static com.zegoggles.smssync.App.LOCAL_LOGV; import static com.zegoggles.smssync.App.TAG; @@ -36,82 +39,117 @@ class MmsSupport { static class MmsDetails { public final boolean inbound; - public final List recipients; - public final List records; - public final List
addresses; - - public final String address; - + public final PersonRecord sender; + public final List recipients; + public final List rawAddresses; public MmsDetails(boolean inbound, - @NonNull List recipients, - List records, - List
addresses) { - - if (recipients.isEmpty()) { - address = "Unknown"; - } else { - address = recipients.get(0); - } + PersonRecord sender, + @NonNull List recipients, + List rawAddresses) { - this.recipients = recipients; this.inbound = inbound; - this.records = records; - this.addresses = addresses; - } - - public MmsDetails(boolean inbound, - String recipient, - PersonRecord record, - Address address) { - this(inbound, Arrays.asList(recipient), Arrays.asList(record), Arrays.asList(address)); + this.sender = sender; + this.recipients = recipients; + this.rawAddresses = rawAddresses; } public boolean isEmpty() { return recipients.isEmpty(); } - public Address[] getAddresses() { - return addresses.toArray(new Address[addresses.size()]); + public PersonRecord getSender() { + return sender; + } + + public List getRecipients() { + return recipients; + } + + public Address[] getRecipientAddresses(AddressStyle style) { + List
recipientAddresses = new ArrayList<>(recipients.size()); + for (PersonRecord recipient : recipients) { + recipientAddresses.add(recipient.getAddress(style)); + } + + return recipientAddresses.toArray(new Address[0]); } public PersonRecord getRecipient() { - return records.get(0); + return recipients.get(0); } - public Address getRecipientAddress() { - return addresses.get(0); + public String getFirstRawAddress() { + if (rawAddresses.size() == 0) { + return "Unknown"; + } + + return rawAddresses.get(0); } } - public MmsDetails getDetails(Uri mmsUri, AddressStyle style) { + public MmsDetails getDetails(Uri mmsUri, AddressStyle style, Map msgMap) { Cursor cursor = resolver.query(Uri.withAppendedPath(mmsUri, "addr"), null, null, null, null); - // TODO: this is probably not the best way to determine if a message is inbound or outbound boolean inbound = true; - final List recipients = new ArrayList(); + List recipients = new ArrayList(); + PersonRecord sender = null; + + List rawAddresses = new ArrayList<>(); + while (cursor != null && cursor.moveToNext()) { final String address = cursor.getString(cursor.getColumnIndex("address")); - //final int type = addresses.getInt(addresses.getColumnIndex("type")); - if (MmsConsts.INSERT_ADDRESS_TOKEN.equals(address)) { - inbound = false; + + rawAddresses.add(address); + + // https://stackoverflow.com/questions/52186442/how-to-get-phone-numbers-of-mms-group-conversation-participants + String PduHeadersFROM = "137"; + String PduHeadersTO = "151"; + String PduHeadersCC = "130"; // https://android.googlesource.com/platform/frameworks/opt/mms/+/4bfcd8501f09763c10255442c2b48fad0c796baa/src/java/com/google/android/mms/pdu/PduHeaders.java + + String type = cursor.getString(cursor.getColumnIndex("type")); + if (type.equals(PduHeadersFROM)) { + PersonRecord record = personLookup.lookupPerson(address); + sender = record; + } else if (type.equals(PduHeadersTO) || type.equals(PduHeadersCC)) { + PersonRecord record = personLookup.lookupPerson(address); + recipients.add(record); } else { - recipients.add(address); + Log.w(TAG, "New logic for to/from did not work, falling back to old logic"); + + if (MmsConsts.INSERT_ADDRESS_TOKEN.equals(address)) { + inbound = false; // probably not the best way to determine if a message is inbound or outbound (legacy logic) + } else { + PersonRecord record = personLookup.lookupPerson(address); + recipients.add(record); + } } } if (cursor != null) cursor.close(); - List records = new ArrayList(recipients.size()); - List
addresses = new ArrayList
(recipients.size()); - if (!recipients.isEmpty()) { - for (String s : recipients) { - PersonRecord record = personLookup.lookupPerson(s); - records.add(record); - addresses.add(record.getAddress(style)); + // If neither of these are true, then the legacy logic will give us a fallback value. + if (Integer.parseInt(msgMap.get(Telephony.BaseMmsColumns.MESSAGE_BOX)) == Telephony.BaseMmsColumns.MESSAGE_BOX_INBOX) { + inbound = true; + } else if (Integer.parseInt(msgMap.get(Telephony.BaseMmsColumns.MESSAGE_BOX)) == Telephony.BaseMmsColumns.MESSAGE_BOX_SENT) { + inbound = false; + } + + // Strip recipient if it's also the sender. Ensures that incoming messages in RCS threads + // between 2 people don't include your own phone number, so that the thread doesn't get split. + if (sender != null) { + List recipientsWithoutSender = new ArrayList<>(); + + for (PersonRecord recipient : recipients) { + if (!recipient.getId().equals(sender.getId())) { + recipientsWithoutSender.add(recipient); + } } + + recipients = recipientsWithoutSender; } - return new MmsDetails(inbound, recipients, records, addresses); + + return new MmsDetails(inbound, sender, recipients, rawAddresses); } public List getMMSBodyParts(final Uri uriPart) throws MessagingException { diff --git a/build.gradle b/build.gradle index a9855f83a..7d90118fd 100644 --- a/build.gradle +++ b/build.gradle @@ -2,9 +2,10 @@ buildscript { repositories { google() jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:3.6.0-beta05' + classpath 'com.android.tools.build:gradle:4.1.3' } } @@ -18,8 +19,12 @@ allprojects { repositories { jcenter() + mavenCentral() maven { url "https://maven.google.com" } maven { url "https://jitpack.io" } + maven { url "https://jcenter.bintray.com" } + // This is the only repo that seems to be hosting "com.firebase:firebase-jobdispatcher" in 2024 + maven { url "https://maven.scijava.org/content/repositories/public/" } google() } } diff --git a/gradle.properties b/gradle.properties index 338ffcf1a..8e821b4d9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,4 +16,3 @@ android.useAndroidX=true android.enableJetifier=true # https://github.com/robolectric/robolectric/issues/5299 android.jetifier.blacklist=.*bcprov.* -android.enableSeparateAnnotationProcessing=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a06f1c7fa..ffed3a254 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Sat Apr 06 02:12:29 CEST 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.3-bin.zip