diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e8214d4d8d0..f6938775c07 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,9 @@ + + + + + + diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreCallLogs.java b/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreCallLogs.java new file mode 100644 index 00000000000..1a21100cff2 --- /dev/null +++ b/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreCallLogs.java @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package io.github.muntashirakon.AppManager.backup.extras; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.BaseColumns; +import android.provider.CallLog; +import android.provider.ContactsContract; +import android.util.JsonReader; +import android.util.JsonWriter; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import io.github.muntashirakon.AppManager.logs.Log; + +// Converted from https://github.com/tmo1/sms-ie/blob/75d2c3da3ef190731970f97414ac2bb5e483ebe2/app/src/main/java/com/github/tmo1/sms_ie/ImportExportCallLog.kt +// to suit our needs. +class BackupRestoreCallLogs extends BackupRestoreSpecial { + public static final String TAG = BackupRestoreCallLogs.class.getSimpleName(); + + public BackupRestoreCallLogs(@NonNull Context context) { + super(context); + } + + @Override + public void backup(@NonNull Writer out) throws IOException { + Map cachedDisplayNames = new HashMap<>(); + try (Cursor cursor = cr.query(CallLog.Calls.CONTENT_URI, null, null, null, null); + JsonWriter jsonWriter = new JsonWriter(out)) { + if (cursor == null || !cursor.moveToFirst()) { + return; + } + jsonWriter.setIndent(" "); + jsonWriter.beginArray(); + int addressIndex = cursor.getColumnIndexOrThrow(CallLog.Calls.NUMBER); + String[] columns = cursor.getColumnNames(); + do { + jsonWriter.beginObject(); + for (int i = 0; i < columns.length; ++i) { + String val = cursor.getString(i); + if (val != null) { + jsonWriter.name(columns[i]).value(val); + } + } + // The call logs do have a CACHED_NAME ("name") field, but it may still be useful to add the current display name, if available + // From the documentation at https://developer.android.com/reference/android/provider/CallLog.Calls#CACHED_NAME + // "The cached name associated with the phone number, if it exists. + // This value is typically filled in by the dialer app for the caching purpose, so it's not guaranteed to be present, and may not be current if the contact information associated with this number has changed." + String address = cursor.getString(addressIndex); + if (address != null) { + String displayName = ContactsUtils.lookupDisplayName(context, cachedDisplayNames, address); + if (displayName != null) { + jsonWriter.name(ContactsContract.PhoneLookup.DISPLAY_NAME).value(displayName); + } + } + jsonWriter.endObject(); + } while (cursor.moveToNext()); + jsonWriter.endArray(); + } + } + + @Override + public void restore(@NonNull Reader in) throws IOException { + JsonReader jsonReader = new JsonReader(in); + List columns; + try (Cursor callLogCursor = cr.query(CallLog.Calls.CONTENT_URI, null, null, null, null)) { + if (callLogCursor == null) { + return; + } + columns = new ArrayList<>(Arrays.asList(callLogCursor.getColumnNames())); + columns.remove(BaseColumns._ID); + columns.remove(BaseColumns._COUNT); + } + jsonReader.beginArray(); + ContentValues callLogMetadata = new ContentValues(); + while (jsonReader.hasNext()) { + jsonReader.beginObject(); + callLogMetadata.clear(); + while (jsonReader.hasNext()) { + String name = jsonReader.nextName(); + String value = jsonReader.nextString(); + if ((columns.contains(name))) { + callLogMetadata.put(name, value); + } + } + Uri insertUri; + if (callLogMetadata.containsKey(CallLog.Calls.NUMBER)) { + insertUri = cr.insert(CallLog.Calls.CONTENT_URI, callLogMetadata); + if (insertUri == null) { + Log.v(TAG, "Call log insert failed!"); + } + } + jsonReader.endObject(); + } + jsonReader.endArray(); + } +} diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreContacts.java b/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreContacts.java new file mode 100644 index 00000000000..9b737164901 --- /dev/null +++ b/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreContacts.java @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package io.github.muntashirakon.AppManager.backup.extras; + +import android.content.ContentProviderOperation; +import android.content.Context; +import android.database.Cursor; +import android.provider.BaseColumns; +import android.provider.ContactsContract; +import android.util.Base64; +import android.util.JsonReader; +import android.util.JsonWriter; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import io.github.muntashirakon.AppManager.logs.Log; + +// Converted from https://github.com/tmo1/sms-ie/blob/75d2c3da3ef190731970f97414ac2bb5e483ebe2/app/src/main/java/com/github/tmo1/sms_ie/ImportExportContacts.kt +// to suit our needs. +class BackupRestoreContacts extends BackupRestoreSpecial { + public static final String TAG = BackupRestoreContacts.class.getSimpleName(); + + public BackupRestoreContacts(@NonNull Context context) { + super(context); + } + + + @Override + public void backup(@NonNull Writer out) throws IOException { + try (Cursor cursor = cr.query(ContactsContract.Contacts.CONTENT_URI, null, + null, null, null); + JsonWriter jsonWriter = new JsonWriter(out)) { + if (cursor == null || !cursor.moveToFirst()) { + return; + } + jsonWriter.setIndent(" "); + jsonWriter.beginArray(); + int contactsIdIndex = cursor.getColumnIndexOrThrow(BaseColumns._ID); + String[] columns = cursor.getColumnNames(); + do { + jsonWriter.beginObject(); + for (int i = 0; i < columns.length; ++i) { + String val = cursor.getString(i); + if (val != null) { + jsonWriter.name(columns[i]).value(val); + } + } + String contactId = cursor.getString(contactsIdIndex); + if (contactId != null) { + writeContactInfo(jsonWriter, contactId); + } + jsonWriter.endObject(); + } while (cursor.moveToNext()); + jsonWriter.endArray(); + } + } + + @Override + public void restore(@NonNull Reader in) throws IOException { + JsonReader jsonReader = new JsonReader(in); + List contactDataFields = new ArrayList<>(); + for (int i = 1; i < 16; ++i) { + contactDataFields.add("data" + i); + } + contactDataFields.add(ContactsContract.Data.MIMETYPE); + try { + jsonReader.beginArray(); + // Loop through Contacts + while (jsonReader.hasNext()) { + jsonReader.beginObject(); + // Loop through Contact fields until we find the array of Raw Contacts + while (jsonReader.hasNext()) { + String name = jsonReader.nextName(); + if (Objects.equals(name, "raw_contacts")) { + jsonReader.beginArray(); + while (jsonReader.hasNext()) { + // See https://developer.android.com/guide/topics/providers/contacts-provider#Transactions + ArrayList ops = new ArrayList<>(); + ContentProviderOperation.Builder op = ContentProviderOperation + .newInsert(ContactsContract.RawContacts.CONTENT_URI) + .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null) + .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null); + ops.add(op.build()); + jsonReader.beginObject(); + // Loop through Raw Contact fields until we find the array of Contacts Data + while (jsonReader.hasNext()) { + name = jsonReader.nextName(); + if (Objects.equals(name, "contacts_data")) { + jsonReader.beginArray(); + while (jsonReader.hasNext()) { + jsonReader.beginObject(); + op = ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0); + while (jsonReader.hasNext()) { + name = jsonReader.nextName(); + String dataValue = jsonReader.nextString(); + boolean base64 = false; + if (name.length() > 10 && name.endsWith("__base64__")) { + base64 = true; + name = name.substring(0, name.length() - 10); + } + if (contactDataFields.contains(name)) { + if (base64) { + op.withValue(name, Base64.decode(dataValue, Base64.NO_WRAP)); + } else { + op.withValue(name, dataValue); + } + } + } + op.withYieldAllowed(true); + ops.add(op.build()); + jsonReader.endObject(); + } + jsonReader.endArray(); + } else { + jsonReader.nextString(); + } + } + try { + cr.applyBatch(ContactsContract.AUTHORITY, ops); + } catch (Exception e) { + Log.e(TAG, "Exception encountered while inserting contact", e); + } + jsonReader.endObject(); + } + jsonReader.endArray(); + } else { + jsonReader.nextString(); + } + } + jsonReader.endObject(); + } + jsonReader.endArray(); + } catch (Exception e) { + Log.e(TAG, "Error importing contacts", e); + } + // TODO: 10/1/23 + } + + private void writeContactInfo(@NonNull JsonWriter jsonWriter, @NonNull String contactId) throws IOException { + try (Cursor cursor = cr.query(ContactsContract.RawContacts.CONTENT_URI, + null, ContactsContract.RawContacts.CONTACT_ID + "=?", new String[]{contactId}, null, null)) { + if (cursor == null || !cursor.moveToFirst()) { + return; + } + int rawContactsIdIndex = cursor.getColumnIndexOrThrow(BaseColumns._ID); + jsonWriter.name("raw_contacts"); + jsonWriter.beginArray(); + String[] columns = cursor.getColumnNames(); + do { + jsonWriter.beginObject(); + for (int i = 0; i < columns.length; ++i) { + String val = cursor.getString(i); + if (val != null) { + jsonWriter.name(columns[i]).value(val); + } + } + String rawContactId = cursor.getString(rawContactsIdIndex); + if (rawContactId != null) { + writeRawContactInfo(jsonWriter, rawContactId); + } + jsonWriter.endObject(); + } while (cursor.moveToNext()); + jsonWriter.endArray(); + } + } + + private void writeRawContactInfo(@NonNull JsonWriter jsonWriter, @NonNull String rawContactId) throws IOException { + try (Cursor dataCursor = cr.query(ContactsContract.Data.CONTENT_URI, null, + ContactsContract.Data.RAW_CONTACT_ID + "=?", new String[]{rawContactId}, null, null)) { + if (dataCursor == null || !dataCursor.moveToFirst()) { + return; + } + jsonWriter.name("contacts_data"); + jsonWriter.beginArray(); + String[] columns = dataCursor.getColumnNames(); + do { + jsonWriter.beginObject(); + for (int i = 0; i < columns.length; ++i) { + if (dataCursor.getType(i) != Cursor.FIELD_TYPE_BLOB) { + String val = dataCursor.getString(i); + if (val != null) { + jsonWriter.name(columns[i]).value(val); + } + } else { + byte[] val = dataCursor.getBlob(i); + if (val != null) { + jsonWriter.name(columns[i] + "__base64__") + .value(Base64.encodeToString(val, Base64.NO_WRAP)); + } + } + } + jsonWriter.endObject(); + } while (dataCursor.moveToNext()); + jsonWriter.endArray(); + } + } +} diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreMessages.java b/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreMessages.java new file mode 100644 index 00000000000..a965bae15eb --- /dev/null +++ b/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreMessages.java @@ -0,0 +1,413 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package io.github.muntashirakon.AppManager.backup.extras; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.provider.BaseColumns; +import android.provider.ContactsContract; +import android.provider.Telephony; +import android.util.Base64; +import android.util.JsonReader; +import android.util.JsonWriter; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import io.github.muntashirakon.AppManager.logs.Log; +import io.github.muntashirakon.io.IoUtils; + +// Converted from https://github.com/tmo1/sms-ie/blob/75d2c3da3ef190731970f97414ac2bb5e483ebe2/app/src/main/java/com/github/tmo1/sms_ie/ImportExportMessages.kt +// to suit our needs +class BackupRestoreMessages extends BackupRestoreSpecial { + public static final String TAG = BackupRestoreMessages.class.getSimpleName(); + // PduHeaders are referenced here https://developer.android.com/reference/android/provider/Telephony.Mms.Addr#TYPE + // and defined here https://android.googlesource.com/platform/frameworks/opt/mms/+/4bfcd8501f09763c10255442c2b48fad0c796baa/src/java/com/google/android/mms/pdu/PduHeaders.java + // but are apparently unavailable in a public class + public static final String PDU_HEADERS_FROM = "137"; + // FIXME: I can't find an officially documented way of getting the Part table URI for API < 29. + // The idea to use "content://mms/part" comes from here: https://stackoverflow.com/a/6446831 + private static final Uri MMS_PART_CONTENT_URI = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? + Telephony.Mms.Part.CONTENT_URI : Uri.withAppendedPath(Telephony.Mms.CONTENT_URI, "part"); + + public BackupRestoreMessages(@NonNull Context context) { + super(context); + } + + @Override + public void backup(@NonNull Writer out) throws IOException { + Map cachedDisplayNames = new HashMap<>(); + try (JsonWriter jsonWriter = new JsonWriter(out)) { + jsonWriter.setIndent(" "); + jsonWriter.beginArray(); + writeSms(jsonWriter, cachedDisplayNames); + writeMms(jsonWriter, cachedDisplayNames); + jsonWriter.endArray(); + } + } + + @Override + public void restore(@NonNull Reader in) throws IOException { + JsonReader jsonReader = new JsonReader(in); + // get column names of local SMS, MMS, and MMS part tables + List smsColumns = new ArrayList<>(); + try (Cursor smsCursor = cr.query(Telephony.Sms.CONTENT_URI, null, null, null, null)) { + if (smsCursor != null) { + smsColumns.addAll(Arrays.asList(smsCursor.getColumnNames())); + } + } + List mmsColumns = new ArrayList<>(); + try (Cursor mmsCursor = cr.query(Telephony.Mms.CONTENT_URI, null, null, null, null)) { + if (mmsCursor != null) { + mmsColumns.addAll(Arrays.asList(mmsCursor.getColumnNames())); + } + } + List partColumns = new ArrayList<>(); + try (Cursor partCursor = cr.query(MMS_PART_CONTENT_URI, null, null, null, null)) { + if (partCursor != null) { + partColumns.addAll(Arrays.asList(partCursor.getColumnNames())); + } + } + Map threadIdMap = new HashMap<>(); + ContentValues messageMetadata = new ContentValues(); + Set addresses = new HashSet<>(); + List parts = new ArrayList<>(); + List binaryData = new ArrayList<>(); + List defaultRequiredColumns = Arrays.asList(BaseColumns._ID, ContactsContract.PhoneLookup.DISPLAY_NAME); + List mmsRequiredColumns = Arrays.asList(Telephony.Mms.Addr._ID, Telephony.Mms.Addr._COUNT, + Telephony.Mms.Addr.MSG_ID, ContactsContract.PhoneLookup.DISPLAY_NAME); + List partsRequiredColumns = Arrays.asList(Telephony.Mms.Part._ID, Telephony.Mms.Part._COUNT, + Telephony.Mms.Part._DATA, Telephony.Mms.Part.MSG_ID, ContactsContract.PhoneLookup.DISPLAY_NAME); + try { + jsonReader.beginArray(); + while (jsonReader.hasNext()) { + jsonReader.beginObject(); + messageMetadata.clear(); + addresses.clear(); + parts.clear(); + binaryData.clear(); + String name; + String value; + String oldThreadId = null; + while (jsonReader.hasNext()) { + name = jsonReader.nextName(); + switch (name) { + case "sender_address": { + jsonReader.beginObject(); + ContentValues address = new ContentValues(); + while (jsonReader.hasNext()) { + String name1 = jsonReader.nextName(); + String value1 = jsonReader.nextString(); + if (!mmsRequiredColumns.contains(name1)) { + address.put(name1, value1); + } + } + addresses.add(address); + jsonReader.endObject(); + break; + } + case "recipient_addresses": { + jsonReader.beginArray(); + while (jsonReader.hasNext()) { + jsonReader.beginObject(); + ContentValues address = new ContentValues(); + while (jsonReader.hasNext()) { + String name1 = jsonReader.nextName(); + String value1 = jsonReader.nextString(); + if (!mmsRequiredColumns.contains(name1)) { + address.put(name1, value1); + } + } + addresses.add(address); + jsonReader.endObject(); + } + jsonReader.endArray(); + break; + } + case "parts": { + jsonReader.beginArray(); + while (jsonReader.hasNext()) { + jsonReader.beginObject(); + ContentValues part = new ContentValues(); + boolean hasBinaryData = false; + while (jsonReader.hasNext()) { + String name1 = jsonReader.nextName(); + String value1 = jsonReader.nextString(); + if (!partsRequiredColumns.contains(name1)) { + part.put(name1, value1); + } + if (Objects.equals(name1, "binary_data")) { + binaryData.add(Base64.decode(value1, Base64.NO_WRAP)); + hasBinaryData = true; + } + } + if (!hasBinaryData) { + binaryData.add(null); + } + parts.add(part); + jsonReader.endObject(); + } + jsonReader.endArray(); + break; + } + case Telephony.Sms.THREAD_ID: { + oldThreadId = jsonReader.nextString(); + if (threadIdMap.containsKey(oldThreadId)) { + messageMetadata.put(Telephony.Sms.THREAD_ID, threadIdMap.get(oldThreadId)); + } + break; + } + default: { + value = jsonReader.nextString(); + if (!defaultRequiredColumns.contains(name)){ + messageMetadata.put(name, value); + } + break; + } + } + } + jsonReader.endObject(); + + boolean isMMS = messageMetadata.containsKey(Telephony.Mms.MESSAGE_TYPE); + // If we don't yet have a thread_id (i.e., the message has a new + // thread_id that we haven't yet encountered and so isn't yet in + // threadIdMap), then we need to get a new thread_id and record the mapping + // between the old and new ones in threadIdMap + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M + && !messageMetadata.containsKey(Telephony.Sms.THREAD_ID)) { + Set addressesSet = new HashSet<>(); + for (ContentValues address : addresses) { + addressesSet.add(address.getAsString(Telephony.Mms.Addr.ADDRESS)); + } + long newThreadId = !isMMS ? Telephony.Threads.getOrCreateThreadId(context, + messageMetadata.getAsString(Telephony.TextBasedSmsColumns.ADDRESS)) + : Telephony.Threads.getOrCreateThreadId(context, addressesSet); + messageMetadata.put(Telephony.Sms.THREAD_ID, newThreadId); + if (oldThreadId != null) { + threadIdMap.put(oldThreadId, String.valueOf(newThreadId)); + } + } + // Log.v(TAG, "Original thread_id: $oldThreadId\t New thread_id: ${messageMetadata.getAsString(Telephony.Sms.THREAD_ID)}") + if (!isMMS) { // insert SMS + Set fieldNames = new HashSet<>(messageMetadata.keySet()); + for (String key : fieldNames) { + if (!smsColumns.contains(key)) { + messageMetadata.remove(key); + } + } + Uri insertUri = cr.insert(Telephony.Sms.CONTENT_URI, messageMetadata); + if (insertUri == null) { + Log.v(TAG, "SMS insert failed!"); + } + } else { // insert MMS + Set fieldNames = new HashSet<>(messageMetadata.keySet()); + for (String key : fieldNames) { + if (!mmsColumns.contains(key)) { + messageMetadata.remove(key); + } + } + Uri insertUri = cr.insert(Telephony.Mms.CONTENT_URI, messageMetadata); + if (insertUri == null) { + Log.v(TAG, "MMS insert failed!"); + } else { + // Log.v(TAG, "MMS insert succeeded!"); + String messageId = insertUri.getLastPathSegment(); + Uri addressUri = Uri.withAppendedPath(Uri.withAppendedPath(Telephony.Mms.CONTENT_URI, messageId), "addr"); + for (ContentValues address1 : addresses) { + address1.put(Telephony.Mms.Addr.MSG_ID, messageId); + Uri insertAddressUri = cr.insert(addressUri, address1); + if (insertAddressUri == null) { + Log.v(TAG, "MMS address insert failed!"); + } /*else { + Log.v(TAG, "MMS address insert succeeded. Address metadata:" + address.toString()); + }*/ + } + Uri partUri = Uri.withAppendedPath(Uri.withAppendedPath(Telephony.Mms.CONTENT_URI, messageId), "part"); + for (int j = 0; j < parts.size(); ++j) { + ContentValues part1 = parts.get(j); + Set partFieldNames = new HashSet<>(part1.keySet()); + for (String key : partFieldNames) { + if (!partColumns.contains(key)) { + part1.remove(key); + } + } + part1.put(Telephony.Mms.Part.MSG_ID, messageId); + Uri insertPartUri = cr.insert(partUri, part1); + if (insertPartUri == null) { + Log.v(TAG, "MMS part insert failed! Part metadata: " + part1); + } else { + byte[] data = binaryData.get(j); + if (data != null) { + try (OutputStream os = cr.openOutputStream(insertPartUri)) { + if (os != null) { + os.write(data); + } else Log.v(TAG, "Failed to open OutputStream!"); + } + } + } + } + } + } + } + jsonReader.endArray(); + } catch (Exception e) { + Log.e(TAG, "Error importing messages", e); + } + } + + private void writeSms(@NonNull JsonWriter jsonWriter, @NonNull Map cachedDisplayNames) throws IOException { + try (Cursor cursor = cr.query(Telephony.Sms.CONTENT_URI, null, null, null, null)) { + if (cursor == null || !cursor.moveToFirst()) { + return; + } + int addressIndex = cursor.getColumnIndexOrThrow(Telephony.Sms.ADDRESS); + String[] columns = cursor.getColumnNames(); + do { + jsonWriter.beginObject(); + for (int i = 0; i < columns.length; ++i) { + String val = cursor.getString(i); + if (val != null) { + jsonWriter.name(columns[i]).value(val); + } + } + String address = cursor.getString(addressIndex); + if (address != null) { + String displayName = ContactsUtils.lookupDisplayName(context, cachedDisplayNames, address); + if (displayName != null) { + jsonWriter.name(ContactsContract.PhoneLookup.DISPLAY_NAME).value(displayName); + } + } + jsonWriter.endObject(); + } while (cursor.moveToNext()); + } + } + + private void writeMms(@NonNull JsonWriter jsonWriter, @NonNull Map cachedDisplayNames) throws IOException { + try (Cursor cursor = cr.query(Telephony.Mms.CONTENT_URI, null, null, null, null)) { + if (cursor == null || !cursor.moveToFirst()) { + return; + } + int msgIdIndex = cursor.getColumnIndexOrThrow(BaseColumns._ID); + String[] columns = cursor.getColumnNames(); + do { + jsonWriter.beginObject(); + for (int i = 0; i < columns.length; ++i) { + String val = cursor.getString(i); + if (val != null) { + jsonWriter.name(columns[i]).value(val); + } + } + // The following is adapted from https://stackoverflow.com/questions/3012287/how-to-read-mms-data-in-android/6446831#6446831 + String msgId = cursor.getString(msgIdIndex); + if (msgId != null) { + writeMmsAddr(jsonWriter, msgId, cachedDisplayNames); + writeMmsPart(jsonWriter, msgId); + } + jsonWriter.endObject(); + } while (cursor.moveToNext()); + } + } + + private void writeMmsAddr(@NonNull JsonWriter jsonWriter, @NonNull String msgId, @NonNull Map cachedDisplayNames) throws IOException { + try (Cursor addressCursor = cr.query(Uri.withAppendedPath(Uri.withAppendedPath(Telephony.Mms.CONTENT_URI, msgId), "addr"), null, null, null, null)) { + if (addressCursor == null || !addressCursor.moveToFirst()) { + return; + } + int addressTypeIndex = addressCursor.getColumnIndexOrThrow(Telephony.Mms.Addr.TYPE); + int addressIndex = addressCursor.getColumnIndexOrThrow(Telephony.Mms.Addr.ADDRESS); + String[] columns = addressCursor.getColumnNames(); + // write sender address object + do { + if (Objects.equals(addressCursor.getString(addressTypeIndex), PDU_HEADERS_FROM)) { + jsonWriter.name("sender_address"); + jsonWriter.beginObject(); + for (int i = 0; i < columns.length; ++i) { + String val = addressCursor.getString(i); + if (val != null) { + jsonWriter.name(columns[i]).value(val); + } + } + String displayName = ContactsUtils.lookupDisplayName(context, cachedDisplayNames, addressCursor.getString(addressIndex)); + if (displayName != null) { + jsonWriter.name(ContactsContract.PhoneLookup.DISPLAY_NAME).value(displayName); + } + jsonWriter.endObject(); + break; + } + } while (addressCursor.moveToNext()); + // write array of recipient address objects + if (!addressCursor.moveToFirst()) { + return; + } + jsonWriter.name("recipient_addresses"); + jsonWriter.beginArray(); + do { + if (!Objects.equals(addressCursor.getString(addressTypeIndex), PDU_HEADERS_FROM)) { + jsonWriter.beginObject(); + for (int i = 0; i < columns.length; ++i) { + String val = addressCursor.getString(i); + if (val != null) { + jsonWriter.name(columns[i]).value(val); + } + } + String displayName = ContactsUtils.lookupDisplayName(context, cachedDisplayNames, addressCursor.getString(addressIndex)); + if (displayName != null) { + jsonWriter.name(ContactsContract.PhoneLookup.DISPLAY_NAME).value(displayName); + } + jsonWriter.endObject(); + } + } while (addressCursor.moveToNext()); + jsonWriter.endArray(); + } + } + + private void writeMmsPart(@NonNull JsonWriter jsonWriter, @NonNull String msgId) throws IOException { + try (Cursor partCursor = cr.query(MMS_PART_CONTENT_URI, null, + "mid=?", new String[]{msgId}, "seq ASC")) { + if (partCursor == null || !partCursor.moveToFirst()) { + return; + } + // write array of MMS parts + jsonWriter.name("parts"); + jsonWriter.beginArray(); + int partIdIndex = partCursor.getColumnIndexOrThrow(Telephony.Mms.Part._ID); + int dataIndex = partCursor.getColumnIndexOrThrow(Telephony.Mms.Part._DATA); + String[] columns = partCursor.getColumnNames(); + do { + jsonWriter.beginObject(); + for (int i = 0; i < columns.length; ++i) { + String val = partCursor.getString(i); + if (val != null) { + jsonWriter.name(columns[i]).value(val); + } + } + if (partCursor.getString(dataIndex) != null) { + try (InputStream inputStream = cr.openInputStream(Uri.withAppendedPath(MMS_PART_CONTENT_URI, partCursor.getString(partIdIndex)))) { + String data = Base64.encodeToString(IoUtils.readFully(inputStream, -1, true), Base64.NO_WRAP); + jsonWriter.name("binary_data").value(data); + } catch (Exception e) { + Log.e(TAG, "Error accessing binary data for MMS message part " + partCursor.getString(partIdIndex), e); + } + } + jsonWriter.endObject(); + } while (partCursor.moveToNext()); + jsonWriter.endArray(); + } + } +} diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreSpecial.java b/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreSpecial.java new file mode 100644 index 00000000000..a1585786d70 --- /dev/null +++ b/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreSpecial.java @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package io.github.muntashirakon.AppManager.backup.extras; + +import android.content.ContentResolver; +import android.content.Context; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; + +public abstract class BackupRestoreSpecial { + @NonNull + protected final Context context; + protected final ContentResolver cr; + + protected BackupRestoreSpecial(@NonNull Context context) { + this.context = context; + this.cr = context.getContentResolver(); + } + + public abstract void backup(@NonNull Writer out) throws IOException; + + public abstract void restore(@NonNull Reader in) throws IOException; +} diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreSpecials.java b/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreSpecials.java new file mode 100644 index 00000000000..aa00e1491dd --- /dev/null +++ b/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/BackupRestoreSpecials.java @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package io.github.muntashirakon.AppManager.backup.extras; + +import android.content.Context; + +import androidx.annotation.NonNull; + +public final class BackupRestoreSpecials { + @NonNull + public static BackupRestoreSpecial getCallLogs(@NonNull Context context) { + return new BackupRestoreCallLogs(context); + } + + @NonNull + public static BackupRestoreSpecial getContacts(@NonNull Context context) { + return new BackupRestoreContacts(context); + } + + @NonNull + public static BackupRestoreSpecial getMessages(@NonNull Context context) { + return new BackupRestoreMessages(context); + } +} diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/ContactsUtils.java b/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/ContactsUtils.java new file mode 100644 index 00000000000..656ab51e1ab --- /dev/null +++ b/app/src/main/java/io/github/muntashirakon/AppManager/backup/extras/ContactsUtils.java @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package io.github.muntashirakon.AppManager.backup.extras; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; + +import androidx.annotation.Nullable; + +import java.util.Map; + +import io.github.muntashirakon.AppManager.utils.TextUtilsCompat; + +final class ContactsUtils { + @Nullable + public static String lookupDisplayName(Context context, Map cachedDisplayNames, String address) { + if (TextUtilsCompat.isEmpty(address)) { + return null; + } + String displayName = cachedDisplayNames.get(address); + if (displayName != null) { + return displayName; + } + Uri uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(address)); + try (Cursor nameCursor = context.getContentResolver().query(uri, new String[]{ContactsContract.PhoneLookup.DISPLAY_NAME}, + null, null, null)) { + if (nameCursor == null || !nameCursor.moveToFirst()) { + return null; + } + displayName = nameCursor.getString(nameCursor.getColumnIndexOrThrow(ContactsContract.PhoneLookup.DISPLAY_NAME)); + cachedDisplayNames.put(address, displayName); + } + return displayName; + } +} diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/details/EditShortcutDialogFragment.java b/app/src/main/java/io/github/muntashirakon/AppManager/details/EditShortcutDialogFragment.java index 612f1c5b70e..3a5fc00aca3 100644 --- a/app/src/main/java/io/github/muntashirakon/AppManager/details/EditShortcutDialogFragment.java +++ b/app/src/main/java/io/github/muntashirakon/AppManager/details/EditShortcutDialogFragment.java @@ -128,7 +128,7 @@ public void afterTextChanged(Editable s) { public Drawable getDrawable(@NonNull String iconResString) { try { return ResourceUtil.getResourceFromName(mPackageManager, iconResString).getDrawable(requireActivity().getTheme()); - } catch (PackageManager.NameNotFoundException ignore) { + } catch (PackageManager.NameNotFoundException | Resources.NotFoundException ignore) { return mPackageManager.getDefaultActivityIcon(); } } diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/main/MainActivity.java b/app/src/main/java/io/github/muntashirakon/AppManager/main/MainActivity.java index 99734be9a22..a7942b47dfc 100644 --- a/app/src/main/java/io/github/muntashirakon/AppManager/main/MainActivity.java +++ b/app/src/main/java/io/github/muntashirakon/AppManager/main/MainActivity.java @@ -64,6 +64,7 @@ import io.github.muntashirakon.AppManager.profiles.ProfilesActivity; import io.github.muntashirakon.AppManager.rules.RulesTypeSelectionDialogFragment; import io.github.muntashirakon.AppManager.runningapps.RunningAppsActivity; +import io.github.muntashirakon.AppManager.self.life.FundingCampaignChecker; import io.github.muntashirakon.AppManager.settings.FeatureController; import io.github.muntashirakon.AppManager.settings.Ops; import io.github.muntashirakon.AppManager.settings.SettingsActivity; @@ -75,6 +76,7 @@ import io.github.muntashirakon.AppManager.utils.StoragePermission; import io.github.muntashirakon.AppManager.utils.UIUtils; import io.github.muntashirakon.dialog.AlertDialogBuilder; +import io.github.muntashirakon.dialog.ScrollableDialogBuilder; import io.github.muntashirakon.dialog.SearchableMultiChoiceDialogBuilder; import io.github.muntashirakon.dialog.SearchableSingleChoiceDialogBuilder; import io.github.muntashirakon.io.Paths; @@ -617,6 +619,12 @@ private void displayChangelogIfRequired() { if (!AppPref.getBoolean(AppPref.PrefKey.PREF_DISPLAY_CHANGELOG_BOOL)) { return; } + if (FundingCampaignChecker.campaignRunning()) { + new ScrollableDialogBuilder(this) + .setMessage(R.string.funding_campaign_dialog_message) + .enableAnchors() + .show(); + } Snackbar.make(findViewById(android.R.id.content), R.string.view_changelog, 3 * 60 * 1000) .setAction(R.string.ok, v -> { long lastVersion = (long) AppPref.get(AppPref.PrefKey.PREF_DISPLAY_CHANGELOG_LAST_VERSION_LONG); diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/self/life/FundingCampaignChecker.java b/app/src/main/java/io/github/muntashirakon/AppManager/self/life/FundingCampaignChecker.java new file mode 100644 index 00000000000..7f89a2c307f --- /dev/null +++ b/app/src/main/java/io/github/muntashirakon/AppManager/self/life/FundingCampaignChecker.java @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package io.github.muntashirakon.AppManager.self.life; + +public class FundingCampaignChecker { + private static final long FUNDING_CAMPAIGN_START = 1671796800000L; + private static final long FUNDING_CAMPAIGN_END = 1680350400000L; + + public static boolean campaignRunning() { + long currentTime = System.currentTimeMillis(); + return currentTime >= FUNDING_CAMPAIGN_START && currentTime <= FUNDING_CAMPAIGN_END; + } +} diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/settings/SettingsActivity.java b/app/src/main/java/io/github/muntashirakon/AppManager/settings/SettingsActivity.java index 7e3ea012afc..723ea26678b 100644 --- a/app/src/main/java/io/github/muntashirakon/AppManager/settings/SettingsActivity.java +++ b/app/src/main/java/io/github/muntashirakon/AppManager/settings/SettingsActivity.java @@ -26,14 +26,12 @@ import io.github.muntashirakon.AppManager.R; import io.github.muntashirakon.AppManager.logs.Log; import io.github.muntashirakon.AppManager.self.life.BuildExpiryChecker; +import io.github.muntashirakon.AppManager.self.life.FundingCampaignChecker; public class SettingsActivity extends BaseActivity implements PreferenceFragmentCompat.OnPreferenceStartFragmentCallback { private static final String SCHEME = "app-manager"; private static final String HOST = "settings"; - private static final long FUNDING_CAMPAIGN_START = 1671796800000L; - private static final long FUNDING_CAMPAIGN_END = 1680350400000L; - @NonNull public static Intent getIntent(@NonNull Context context, @Nullable String... paths) { Intent intent = new Intent(context, SettingsActivity.class); @@ -70,9 +68,7 @@ protected void onAuthenticated(Bundle savedInstanceState) { View buildExpiringNotice = findViewById(R.id.app_manager_expiring_notice); buildExpiringNotice.setVisibility(BuildExpiryChecker.buildExpired() == null ? View.VISIBLE : View.GONE); View fundingCampaignNotice = findViewById(R.id.funding_campaign_notice); - long currentTime = System.currentTimeMillis(); - boolean campaignOngoing = currentTime >= FUNDING_CAMPAIGN_START && currentTime <= FUNDING_CAMPAIGN_END; - fundingCampaignNotice.setVisibility(campaignOngoing ? View.VISIBLE : View.GONE); + fundingCampaignNotice.setVisibility(FundingCampaignChecker.campaignRunning() ? View.VISIBLE : View.GONE); Uri uri = getIntent().getData(); if (uri != null && SCHEME.equals(uri.getScheme()) && HOST.equals(uri.getHost()) && uri.getPath() != null) { diff --git a/app/src/main/java/io/github/muntashirakon/proc/ProcFs.java b/app/src/main/java/io/github/muntashirakon/proc/ProcFs.java index 041d0084392..fdeb8f00402 100644 --- a/app/src/main/java/io/github/muntashirakon/proc/ProcFs.java +++ b/app/src/main/java/io/github/muntashirakon/proc/ProcFs.java @@ -158,15 +158,23 @@ public String getWchan(int pid) { @Nullable public String getCurrentContext(int pid) { - return getStringOrNull(Paths.build(procRoot, String.valueOf(pid), ATTR, CURRENT)); + String context = getStringOrNull(Paths.build(procRoot, String.valueOf(pid), ATTR, CURRENT)); + if (context == null) { + return null; + } + return context.trim(); } @Nullable public String getPreviousContext(int pid) { - return getStringOrNull(Paths.build(procRoot, String.valueOf(pid), ATTR, PREV)); + String context = getStringOrNull(Paths.build(procRoot, String.valueOf(pid), ATTR, PREV)); + if (context == null) { + return null; + } + return context.trim(); } private String getStringOrNull(@Nullable Path file) { - return file != null ? file.getContentAsString() : null; + return file != null ? file.getContentAsString(null) : null; } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e929836523e..1daac702e6b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1302,5 +1302,6 @@ Select format for exporting app list XML Markdown + We\'re running a funding campaign for App Manager for a limited period. Visit opencollective.com/app-manager to learn more about the campaign. This notice will not be displayed again, but you can find it in the Settings page during the entire campaign. We\'re running a funding campaign for App Manager for a limited period. learn moreā€¦