From 4ab96ce56f91a9d999b50fc5aa6e0036880e62d9 Mon Sep 17 00:00:00 2001 From: Johan Date: Thu, 24 Oct 2024 22:20:07 +0200 Subject: [PATCH] Added extra options - Encrypted and plaintext (xml) backup / restore. - Removed apk expire. - Import unencrypted WhatsApp database (this option might be outdated). - Choose between passphrase protection and the Android screenlock. - Choice for the backup location: for Android 4 - 10, internal or removable storage; for Android 11+ any directory can be chosen. - Set the maptype in the place picker. - Option to treat view-once media as normal media. - Option to ignore remote deletion. - Choose between FCM or websocket notification delivery. - Option to delete only the media from a message, not the rest of the message. This does not remove the message or media on linked devices. - Removed forward limit. - Enabled internal preferences. Use at your own risk. - Added options to select who can add you to a group. - Activated chat folders. --- README.md | 49 +- app/build.gradle.kts | 44 +- app/src/main/AndroidManifest.xml | 19 +- .../securesms/AppInitialization.java | 4 +- .../securesms/ApplicationContext.java | 3 + .../thoughtcrime/securesms/ExitActivity.java | 33 + .../securesms/ImportExportActivity.java | 46 ++ .../securesms/ImportExportFragment.java | 583 ++++++++++++++++++ .../securesms/ImportWhatsappDialog.java | 57 ++ .../securesms/PassphrasePromptActivity.java | 4 + .../components/location/SignalMapView.java | 16 +- .../app/chats/ChatsSettingsFragment.kt | 141 ++++- .../settings/app/chats/ChatsSettingsState.kt | 11 + .../app/chats/ChatsSettingsViewModel.kt | 88 +++ .../app/privacy/PrivacySettingsFragment.kt | 74 ++- .../app/privacy/PrivacySettingsState.kt | 3 + .../app/privacy/PrivacySettingsViewModel.kt | 37 ++ .../AdvancedPrivacySettingsFragment.kt | 11 + .../advanced/AdvancedPrivacySettingsState.kt | 3 + .../AdvancedPrivacySettingsViewModel.kt | 9 + .../advanced/FCMPreferenceFunctions.java | 127 ++++ .../ConversationListFragment.java | 7 + .../database/EncryptedBackupExporter.java | 508 +++++++++++++++ .../securesms/database/MediaTable.kt | 23 + .../securesms/database/MessageTable.kt | 26 + .../database/PlaintextBackupExporter.java | 147 +++++ .../database/PlaintextBackupImporter.java | 153 +++++ .../securesms/database/WhatsappBackup.java | 267 ++++++++ .../database/WhatsappBackupImporter.java | 273 ++++++++ .../securesms/database/XmlBackup.java | 19 +- .../database/whatsapp/WaDbContext.java | 57 ++ .../database/whatsapp/WaDbOpenHelper.java | 26 + .../v2/processing/GroupsV2StateProcessor.kt | 23 +- .../securesms/keyvalue/MiscellaneousValues.kt | 2 +- .../securesms/maps/PlacePickerActivity.java | 54 ++ .../securesms/megaphone/Megaphones.java | 6 +- .../messages/DataMessageProcessor.kt | 36 +- .../messages/SyncMessageProcessor.kt | 3 +- .../securesms/util/AttachmentUtil.java | 9 +- .../securesms/util/BackupUtil.java | 23 +- .../securesms/util/FileUtilsJW.java | 150 +++++ .../securesms/util/RemoteConfig.kt | 17 +- .../securesms/util/StorageUtil.java | 149 ++++- .../securesms/util/TextSecurePreferences.java | 122 +++- .../thoughtcrime/securesms/util/UriUtils.java | 119 ++++ .../org/thoughtcrime/securesms/util/Util.java | 16 +- .../ic_content_copy_white_24dp.webp | Bin 0 -> 156 bytes .../res/drawable-hdpi/ic_lock_white_24dp.webp | Bin 0 -> 262 bytes app/src/main/res/drawable-hdpi/whatsapp.png | Bin 0 -> 547 bytes .../ic_content_copy_white_24dp.webp | Bin 0 -> 96 bytes .../res/drawable-mdpi/ic_lock_white_24dp.webp | Bin 0 -> 162 bytes app/src/main/res/drawable-mdpi/whatsapp.png | Bin 0 -> 420 bytes .../ic_content_copy_white_24dp.webp | Bin 0 -> 136 bytes .../drawable-xhdpi/ic_lock_white_24dp.webp | Bin 0 -> 328 bytes app/src/main/res/drawable-xhdpi/whatsapp.png | Bin 0 -> 676 bytes .../ic_content_copy_white_24dp.webp | Bin 0 -> 194 bytes .../drawable-xxhdpi/ic_lock_white_24dp.webp | Bin 0 -> 430 bytes app/src/main/res/drawable-xxhdpi/whatsapp.png | Bin 0 -> 993 bytes .../ic_content_copy_white_24dp.webp | Bin 0 -> 206 bytes .../main/res/drawable-xxxhdpi/whatsapp.png | Bin 0 -> 1199 bytes .../main/res/layout/activity_place_picker.xml | 32 + .../res/layout/dialog_import_whatsapp.xml | 31 + .../res/layout/import_export_fragment.xml | 266 ++++++++ app/src/main/res/menu/text_secure_normal.xml | 4 + app/src/main/res/values-ar/strings1.xml | 27 + app/src/main/res/values-be/strings1.xml | 5 + app/src/main/res/values-bg/strings1.xml | 28 + app/src/main/res/values-ca/strings1.xml | 29 + app/src/main/res/values-cs/strings1.xml | 28 + app/src/main/res/values-da/strings1.xml | 27 + app/src/main/res/values-de/strings1.xml | 36 ++ app/src/main/res/values-el/strings1.xml | 27 + app/src/main/res/values-es/strings1.xml | 29 + app/src/main/res/values-et/strings1.xml | 9 + app/src/main/res/values-eu/strings1.xml | 8 + app/src/main/res/values-fa/strings1.xml | 8 + app/src/main/res/values-fi/strings1.xml | 28 + app/src/main/res/values-fr/strings1.xml | 29 + app/src/main/res/values-ga/strings1.xml | 5 + app/src/main/res/values-gl/strings1.xml | 8 + app/src/main/res/values-he/strings1.xml | 11 + app/src/main/res/values-hr/strings1.xml | 8 + app/src/main/res/values-hu/strings1.xml | 8 + app/src/main/res/values-id/strings1.xml | 11 + app/src/main/res/values-in/strings1.xml | 8 + app/src/main/res/values-it/strings1.xml | 27 + app/src/main/res/values-iw/strings1.xml | 8 + app/src/main/res/values-ja/strings1.xml | 26 + app/src/main/res/values-km/strings1.xml | 8 + app/src/main/res/values-kn/strings1.xml | 15 + app/src/main/res/values-ko/strings1.xml | 8 + app/src/main/res/values-ku/strings1.xml | 45 ++ app/src/main/res/values-lg/strings1.xml | 8 + app/src/main/res/values-lt/strings1.xml | 8 + app/src/main/res/values-mk/strings1.xml | 6 + app/src/main/res/values-my/strings1.xml | 8 + app/src/main/res/values-nb/strings1.xml | 8 + app/src/main/res/values-nl/strings1.xml | 98 +++ app/src/main/res/values-nn/strings1.xml | 8 + app/src/main/res/values-no/strings1.xml | 23 + app/src/main/res/values-pl/strings1.xml | 28 + app/src/main/res/values-pt-rBR/strings1.xml | 30 + app/src/main/res/values-pt/strings1.xml | 30 + app/src/main/res/values-ro/strings1.xml | 28 + app/src/main/res/values-ru/strings1.xml | 27 + app/src/main/res/values-sk/strings1.xml | 29 + app/src/main/res/values-sl/strings1.xml | 27 + app/src/main/res/values-sq/strings1.xml | 8 + app/src/main/res/values-sr/strings1.xml | 8 + app/src/main/res/values-sv/strings1.xml | 29 + app/src/main/res/values-ta/strings1.xml | 8 + app/src/main/res/values-te/strings1.xml | 42 ++ app/src/main/res/values-th/strings1.xml | 8 + app/src/main/res/values-tr/strings1.xml | 27 + app/src/main/res/values-uk/strings1.xml | 29 + app/src/main/res/values-vi/strings1.xml | 29 + app/src/main/res/values-zh-rCN/strings1.xml | 29 + app/src/main/res/values-zh-rTW/strings1.xml | 9 + app/src/main/res/values/arrays1.xml | 37 ++ .../main/res/values/firebase_messaging.xml | 8 +- app/src/main/res/values/strings1.xml | 140 +++++ gradle/verification-metadata.xml | 6 + 122 files changed, 5108 insertions(+), 119 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/ExitActivity.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/ImportExportActivity.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/ImportExportFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/ImportWhatsappDialog.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/FCMPreferenceFunctions.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/EncryptedBackupExporter.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/PlaintextBackupExporter.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/PlaintextBackupImporter.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/WhatsappBackup.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/WhatsappBackupImporter.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/whatsapp/WaDbContext.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/whatsapp/WaDbOpenHelper.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/FileUtilsJW.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/UriUtils.java create mode 100644 app/src/main/res/drawable-hdpi/ic_content_copy_white_24dp.webp create mode 100644 app/src/main/res/drawable-hdpi/ic_lock_white_24dp.webp create mode 100644 app/src/main/res/drawable-hdpi/whatsapp.png create mode 100644 app/src/main/res/drawable-mdpi/ic_content_copy_white_24dp.webp create mode 100644 app/src/main/res/drawable-mdpi/ic_lock_white_24dp.webp create mode 100644 app/src/main/res/drawable-mdpi/whatsapp.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_content_copy_white_24dp.webp create mode 100644 app/src/main/res/drawable-xhdpi/ic_lock_white_24dp.webp create mode 100644 app/src/main/res/drawable-xhdpi/whatsapp.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_content_copy_white_24dp.webp create mode 100644 app/src/main/res/drawable-xxhdpi/ic_lock_white_24dp.webp create mode 100644 app/src/main/res/drawable-xxhdpi/whatsapp.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_content_copy_white_24dp.webp create mode 100644 app/src/main/res/drawable-xxxhdpi/whatsapp.png create mode 100644 app/src/main/res/layout/dialog_import_whatsapp.xml create mode 100644 app/src/main/res/layout/import_export_fragment.xml create mode 100644 app/src/main/res/values-ar/strings1.xml create mode 100644 app/src/main/res/values-be/strings1.xml create mode 100644 app/src/main/res/values-bg/strings1.xml create mode 100644 app/src/main/res/values-ca/strings1.xml create mode 100644 app/src/main/res/values-cs/strings1.xml create mode 100644 app/src/main/res/values-da/strings1.xml create mode 100644 app/src/main/res/values-de/strings1.xml create mode 100644 app/src/main/res/values-el/strings1.xml create mode 100644 app/src/main/res/values-es/strings1.xml create mode 100644 app/src/main/res/values-et/strings1.xml create mode 100644 app/src/main/res/values-eu/strings1.xml create mode 100644 app/src/main/res/values-fa/strings1.xml create mode 100644 app/src/main/res/values-fi/strings1.xml create mode 100644 app/src/main/res/values-fr/strings1.xml create mode 100644 app/src/main/res/values-ga/strings1.xml create mode 100644 app/src/main/res/values-gl/strings1.xml create mode 100644 app/src/main/res/values-he/strings1.xml create mode 100644 app/src/main/res/values-hr/strings1.xml create mode 100644 app/src/main/res/values-hu/strings1.xml create mode 100644 app/src/main/res/values-id/strings1.xml create mode 100644 app/src/main/res/values-in/strings1.xml create mode 100644 app/src/main/res/values-it/strings1.xml create mode 100644 app/src/main/res/values-iw/strings1.xml create mode 100644 app/src/main/res/values-ja/strings1.xml create mode 100644 app/src/main/res/values-km/strings1.xml create mode 100644 app/src/main/res/values-kn/strings1.xml create mode 100644 app/src/main/res/values-ko/strings1.xml create mode 100644 app/src/main/res/values-ku/strings1.xml create mode 100644 app/src/main/res/values-lg/strings1.xml create mode 100644 app/src/main/res/values-lt/strings1.xml create mode 100644 app/src/main/res/values-mk/strings1.xml create mode 100644 app/src/main/res/values-my/strings1.xml create mode 100644 app/src/main/res/values-nb/strings1.xml create mode 100644 app/src/main/res/values-nl/strings1.xml create mode 100644 app/src/main/res/values-nn/strings1.xml create mode 100644 app/src/main/res/values-no/strings1.xml create mode 100644 app/src/main/res/values-pl/strings1.xml create mode 100644 app/src/main/res/values-pt-rBR/strings1.xml create mode 100644 app/src/main/res/values-pt/strings1.xml create mode 100644 app/src/main/res/values-ro/strings1.xml create mode 100644 app/src/main/res/values-ru/strings1.xml create mode 100644 app/src/main/res/values-sk/strings1.xml create mode 100644 app/src/main/res/values-sl/strings1.xml create mode 100644 app/src/main/res/values-sq/strings1.xml create mode 100644 app/src/main/res/values-sr/strings1.xml create mode 100644 app/src/main/res/values-sv/strings1.xml create mode 100644 app/src/main/res/values-ta/strings1.xml create mode 100644 app/src/main/res/values-te/strings1.xml create mode 100644 app/src/main/res/values-th/strings1.xml create mode 100644 app/src/main/res/values-tr/strings1.xml create mode 100644 app/src/main/res/values-uk/strings1.xml create mode 100644 app/src/main/res/values-vi/strings1.xml create mode 100644 app/src/main/res/values-zh-rCN/strings1.xml create mode 100644 app/src/main/res/values-zh-rTW/strings1.xml create mode 100644 app/src/main/res/values/arrays1.xml create mode 100644 app/src/main/res/values/strings1.xml diff --git a/README.md b/README.md index b9cba68da25..1dcbd314746 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# Signal Android +# Signal Android -Signal is a simple, powerful, and secure messenger. +Signal is a messaging app for simple private communication with friends. -Signal uses your phone's data connection (WiFi/3G/4G/5G) to communicate securely. Millions of people use Signal every day for free and instantaneous communication anywhere in the world. Send and receive high-fidelity messages, participate in HD voice/video calls, and explore a growing set of new features that help you stay connected. Signal’s advanced privacy-preserving technology is always enabled, so you can focus on sharing the moments that matter with the people who matter to you. +Signal uses your phone's data connection (WiFi/3G/4G) to communicate securely, optionally supports plain SMS/MMS to function as a unified messenger, and can also encrypt the stored messages on your phone. -Currently available on the Play Store and [signal.org](https://signal.org/android/apk/). +Currently available on the Play store and [signal.org](https://signal.org/android/apk/). Get it on Google Play @@ -18,9 +18,14 @@ Want to live life on the bleeding edge and help out with testing? You can subscribe to Signal Android Beta releases here: https://play.google.com/apps/testing/org.thoughtcrime.securesms - + If you're interested in a life of peace and tranquility, stick with the standard releases. +## Contributing Translations +Interested in helping to translate Signal? Contribute here: + +https://www.transifex.com/projects/p/signal-android/ + ## Contributing Code If you're new to the Signal codebase, we recommend going through our issues and picking out a simple bug to fix (check the "easy" label in our issues) in order to get yourself familiar. Also please have a look at the [CONTRIBUTING.md](https://github.com/signalapp/Signal-Android/blob/main/CONTRIBUTING.md), that might answer some of your questions. @@ -28,7 +33,37 @@ If you're new to the Signal codebase, we recommend going through our issues and For larger changes and feature ideas, we ask that you propose it on the [unofficial Community Forum](https://community.signalusers.org) for a high-level discussion with the wider community before implementation. ## Contributing Ideas -Have something you want to say about Signal projects or want to be part of the conversation? Get involved in the [community forum](https://community.signalusers.org). +Have something you want to say about Open Whisper Systems projects or want to be part of the conversation? Get involved in the [community forum](https://community.signalusers.org). + +## WhatsApp Data Import + +This is based on code contributed by Samuel Welten (https://github.com/jukefoxer/Signal-Android) and Wollwolke +(https://github.com/Wollwolke/Signal-Android/tree/feature/wa-db-import). Thank you both for this. + +This fork of the Signal App provides a method to import one's WhatsApp conversations. It's currently still a pretty tedious process, but at least it's possible. + +### What works + +* Import 1-to-1 text conversation threads. +* Import group chat conversations if a group chat with the same name is set up in the Signal App. +* Importing images and videos messages from WhatsApp chats. + +### What doesn't work + +* Multimedia messages other than images and videos are currently not imported. +* It's pretty slow (10 seconds per 1000 messages). + +### How to do it + +* Extract your unencrypted msgstore.db from your WhatsApp installation. There are several methods to do so. WhatsAppDump seems to offer a possibility that doesn't require rooting the device. A more detailed description of how to do so might be added here in the future. +* Copy the msgstore.db file to the top level directory of your internal storage +* Make an encrypted Backup of your Signal Messages using the built-in feature of the Signal App. +* Build and install this version of the Signal App and import the encrypted Backup of your signal messages. +* You might have to go to the app permission settings and give it the permission to manage all of the external storage. +* Go to Backup => Import WhatsApp to start the import. +* Be patient until it finishes. +* If you're happy with the WhatsApp import create another encrypted backup of all Signal messages. +* Install the original Signal app again and import the encrypted Backup. Help ==== @@ -54,7 +89,7 @@ The form and manner of this distribution makes it eligible for export under the ## License -Copyright 2013-2024 Signal Messenger, LLC +Copyright 2013-2023 Signal Licensed under the GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c4f27935aa9..a87889d6993 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,10 +22,19 @@ plugins { apply(from = "static-ips.gradle.kts") val canonicalVersionCode = 1476 -val canonicalVersionName = "7.21.5" +val canonicalVersionName = "7.21.5.0-JW" val currentHotfixVersion = 0 val maxHotfixVersions = 100 +// JW: re-added +val abiPostFix: Map = mapOf( + "universal" to 0, + "armeabi-v7a" to 1, + "arm64-v8a" to 2, + "x86" to 3, + "x86_64" to 4 +) + val keystores: Map = mapOf("debug" to loadKeystoreProperties("keystore.debug.properties")) val selectableVariants = listOf( @@ -183,8 +192,8 @@ android { manifestPlaceholders["mapsKey"] = "AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U" - buildConfigField("long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L") - buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"") + buildConfigField("long", "BUILD_TIMESTAMP", "1000L") // JW: fixed time for reproducible builds, is not used anyway + buildConfigField("String", "GIT_HASH", "\"000000\"") // JW buildConfigField("String", "SIGNAL_URL", "\"https://chat.signal.org\"") buildConfigField("String", "STORAGE_URL", "\"https://storage.signal.org\"") buildConfigField("String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\"") @@ -292,6 +301,7 @@ android { getByName("release") { isMinifyEnabled = true proguardFiles(*buildTypes["debug"].proguardFiles.toTypedArray()) + manifestPlaceholders["mapsKey"] = getMapsKey() // JW buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Release\"") } @@ -422,24 +432,17 @@ android { outputs .map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl } .forEach { output -> - if (output.baseName.contains("nightly")) { - var tag = getCurrentGitTag() - if (!tag.isNullOrEmpty()) { - if (tag.startsWith("v")) { - tag = tag.substring(1) - } - output.versionNameOverride = tag - output.outputFileName = output.outputFileName.replace(".apk", "-${output.versionNameOverride}.apk") - } else { - output.outputFileName = output.outputFileName.replace(".apk", "-$versionName.apk") - } - } else { - output.outputFileName = output.outputFileName.replace(".apk", "-$versionName.apk") - - if (currentHotfixVersion >= maxHotfixVersions) { - throw AssertionError("Hotfix version is too large!") - } + // JW: rewrote section + output.outputFileName = output.outputFileName.replace(".apk", "-$versionName.apk") + + val abiName: String = output.getFilter("ABI") ?: "universal" + val postFix: Int = abiPostFix[abiName]!! + + if (postFix >= maxHotfixVersions) { + throw AssertionError("maxHotfixVersions is too large") } + + output.versionCodeOverride = canonicalVersionCode * maxHotfixVersions + postFix } } @@ -485,6 +488,7 @@ dependencies { implementation(project(":photoview")) implementation(project(":core-ui")) + implementation("net.lingala.zip4j:zip4j:2.11.5") // JW: added implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.appcompat) { version { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ba245937186..99c76eb78a7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,8 +29,9 @@ - + + + @@ -104,6 +105,10 @@ android:theme="@style/TextSecure.LightTheme" android:largeHeap="true"> + + + @@ -681,12 +686,22 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:exported="false"/> + + + + + + + = 21) { + finishAndRemoveTask(); + } else { + finish(); + } + + System.exit(0); + } + + public static void exitAndRemoveFromRecentApps(Activity activity) { + Intent intent = new Intent(activity, ExitActivity.class); + + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_CLEAR_TASK + | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS + | Intent.FLAG_ACTIVITY_NO_ANIMATION); + + activity.startActivity(intent); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ImportExportActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ImportExportActivity.java new file mode 100644 index 00000000000..9e318873c95 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ImportExportActivity.java @@ -0,0 +1,46 @@ +package org.thoughtcrime.securesms; + +import android.os.Bundle; +import android.view.MenuItem; + +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicTheme; + + +public class ImportExportActivity extends PassphraseRequiredActivity { + + @SuppressWarnings("unused") + private static final String TAG = ImportExportActivity.class.getSimpleName(); + + private DynamicTheme dynamicTheme = new DynamicTheme(); + private DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + assert getSupportActionBar() != null; + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + initFragment(android.R.id.content, new ImportExportFragment(), dynamicLanguage.getCurrentLocale()); + } + + @Override + public void onResume() { + dynamicTheme.onResume(this); + super.onResume(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + super.onOptionsItemSelected(item); + + switch (item.getItemId()) { + case android.R.id.home: finish(); return true; + } + + return false; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ImportExportFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ImportExportFragment.java new file mode 100644 index 00000000000..8d1fe9d7c99 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ImportExportFragment.java @@ -0,0 +1,583 @@ +package org.thoughtcrime.securesms; + +import android.Manifest; +import androidx.annotation.Nullable; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.appcompat.app.AlertDialog; + +import android.provider.Settings; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.backup.BackupDialog; +import org.thoughtcrime.securesms.conversationlist.ConversationListFragment; +import org.thoughtcrime.securesms.database.EncryptedBackupExporter; +import org.thoughtcrime.securesms.database.NoExternalStorageException; +import org.thoughtcrime.securesms.database.PlaintextBackupExporter; +import org.thoughtcrime.securesms.database.PlaintextBackupImporter; +import org.thoughtcrime.securesms.database.WhatsappBackupImporter; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.UriUtils; + +import java.io.File; +import java.io.IOException; + + +public class ImportExportFragment extends Fragment { + + private static final String TAG = ImportExportFragment.class.getSimpleName(); + + private static final int SUCCESS = 0; + private static final int NO_SD_CARD = 1; + private static final int ERROR_IO = 2; + private static final short CHOOSE_PLAINTEXT_IMPORT_LOCATION_REQUEST_CODE = 3; + private static final short CHOOSE_PLAINTEXT_EXPORT_LOCATION_REQUEST_CODE = 4; + private static final short CHOOSE_FULL_IMPORT_LOCATION_REQUEST_CODE = 5; + private static final short CHOOSE_FULL_EXPORT_LOCATION_REQUEST_CODE = 6; + + private ProgressDialog progressDialog; + + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) { + View layout = inflater.inflate(R.layout.import_export_fragment, container, false); + View importWhatsappView = layout.findViewById(R.id.import_whatsapp_backup); + View importPlaintextView = layout.findViewById(R.id.import_plaintext_backup); + View importEncryptedView = layout.findViewById(R.id.import_encrypted_backup); + View exportPlaintextView = layout.findViewById(R.id.export_plaintext_backup); + View exportEncryptedView = layout.findViewById(R.id.export_encrypted_backup); + + importWhatsappView.setOnClickListener(v -> handleImportWhatsappBackup()); + importPlaintextView.setOnClickListener(v -> handleImportPlaintextBackup()); + importEncryptedView.setOnClickListener(v -> handleImportEncryptedBackup()); + exportPlaintextView.setOnClickListener(v -> handleExportPlaintextBackup()); + exportEncryptedView.setOnClickListener(v -> handleExportEncryptedBackup()); + + return layout; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (progressDialog != null && progressDialog.isShowing()) { + progressDialog.dismiss(); + progressDialog = null; + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + } + + @SuppressWarnings("CodeBlock2Expr") + @SuppressLint("InlinedApi") + private void handleImportWhatsappBackup() { + CheckAndGetAccessPermissionApi30(); + if (existsWhatsAppMessageDatabase()) { + AlertDialog.Builder builder = ImportWhatsappDialog.getWhatsappBackupDialog(getActivity()); + builder.setPositiveButton(getActivity().getString(R.string.ImportFragment_import), (dialog, which) -> { + Permissions.with(ImportExportFragment.this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.ImportExportFragment_signal_needs_the_storage_permission_in_order_to_read_from_external_storage_but_it_has_been_permanently_denied)) + .onAllGranted(() -> new ImportWhatsappBackupTask(ImportWhatsappDialog.isImportGroups(), ImportWhatsappDialog.isAvoidDuplicates(), ImportWhatsappDialog.isImportMedia()).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)) + .onAnyDenied(() -> Toast.makeText(getContext(), R.string.ImportExportFragment_signal_needs_the_storage_permission_in_order_to_read_from_external_storage, Toast.LENGTH_LONG).show()) + .execute(); + }); + builder.setNegativeButton(getActivity().getString(R.string.ImportFragment_cancel), null); + builder.show(); + } else { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setMessage(getActivity().getString(R.string.ImportFragment_no_whatsapp_backup_found)) + .setCancelable(false) + .setPositiveButton(getActivity().getString(R.string.ImportFragment_restore_ok), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + } + }); + AlertDialog alert = builder.create(); + alert.show(); + } + } + + @SuppressLint("StaticFieldLeak") + public class ImportWhatsappBackupTask extends AsyncTask { + + private final boolean importGroups; + private final boolean importMedia; + private final boolean avoidDuplicates; + + public ImportWhatsappBackupTask(boolean importGroups, boolean avoidDuplicates, boolean importMedia) { + this.importGroups = importGroups; + this.avoidDuplicates = avoidDuplicates; + this.importMedia = importMedia; + } + + @Override + protected void onPreExecute() { + progressDialog = new ProgressDialog(getActivity()); + progressDialog.setTitle(getActivity().getString(R.string.ImportFragment_importing)); + progressDialog.setMessage(getActivity().getString(R.string.ImportFragment_import_whatsapp_backup_elipse)); + progressDialog.setCancelable(false); + progressDialog.setIndeterminate(false); + progressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); + progressDialog.show(); + } + + protected void onPostExecute(Integer result) { + Context context = getActivity(); + + if (progressDialog != null) + progressDialog.dismiss(); + + if (context == null) + return; + + switch (result) { + case NO_SD_CARD: + Toast.makeText(context, + context.getString(R.string.ImportFragment_no_whatsapp_backup_found), + Toast.LENGTH_LONG).show(); + break; + case ERROR_IO: + Toast.makeText(context, + context.getString(R.string.ImportFragment_error_importing_backup), + Toast.LENGTH_LONG).show(); + break; + case SUCCESS: + Toast.makeText(context, + context.getString(R.string.ImportFragment_import_complete), + Toast.LENGTH_LONG).show(); + break; + } + } + + @Override + protected Integer doInBackground(Void... params) { + try { + WhatsappBackupImporter.importWhatsappFromSd(getActivity(), progressDialog, importGroups, avoidDuplicates, importMedia); + return SUCCESS; + } catch (NoExternalStorageException e) { + Log.w(TAG, e); + return NO_SD_CARD; + } catch (IOException e) { + Log.w(TAG, e); + return ERROR_IO; + } + } + } + + @SuppressWarnings("CodeBlock2Expr") + @SuppressLint("InlinedApi") + private void handleImportPlaintextBackup() { + CheckAndGetAccessPermissionApi30(); + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setIcon(R.drawable.symbol_error_triangle_fill_24); + builder.setTitle(getActivity().getString(R.string.ImportFragment_import_plaintext_backup)); + builder.setMessage(getActivity().getString(R.string.ImportFragment_this_will_import_messages_from_a_plaintext_backup)); + builder.setPositiveButton(getActivity().getString(R.string.ImportFragment_import), (dialog, which) -> { + if (Build.VERSION.SDK_INT >= 30 && SignalStore.settings().getSignalBackupDirectory() == null ) { + BackupDialog.showChooseBackupLocationDialog(this, CHOOSE_PLAINTEXT_IMPORT_LOCATION_REQUEST_CODE); + } else if (Build.VERSION.SDK_INT < 29) { + Permissions.with(ImportExportFragment.this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.ImportExportFragment_signal_needs_the_storage_permission_in_order_to_read_from_external_storage_but_it_has_been_permanently_denied)) + .onAllGranted(() -> + new ImportPlaintextBackupTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)) + .onAnyDenied(() -> Toast.makeText(getContext(), R.string.ImportExportFragment_signal_needs_the_storage_permission_in_order_to_read_from_external_storage, Toast.LENGTH_LONG).show()) + .execute(); + } else { + new ImportPlaintextBackupTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + }); + builder.setNegativeButton(getActivity().getString(R.string.ImportFragment_cancel), null); + builder.show(); + } + + @SuppressWarnings("CodeBlock2Expr") + @SuppressLint("InlinedApi") + private void handleExportPlaintextBackup() { + CheckAndGetAccessPermissionApi30(); + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setIcon(R.drawable.symbol_error_triangle_fill_24); + builder.setTitle(getActivity().getString(R.string.ExportFragment_export_plaintext_to_storage)); + builder.setMessage(getActivity().getString(R.string.ExportFragment_warning_this_will_export_the_plaintext_contents)); + builder.setPositiveButton(getActivity().getString(R.string.ExportFragment_export), (dialog, which) -> { + if (Build.VERSION.SDK_INT >= 30 && SignalStore.settings().getSignalBackupDirectory() == null ) { + BackupDialog.showChooseBackupLocationDialog(this, CHOOSE_PLAINTEXT_EXPORT_LOCATION_REQUEST_CODE); + } else if (Build.VERSION.SDK_INT < 29) { + Permissions.with(ImportExportFragment.this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.ImportExportFragment_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) + .onAllGranted(() -> new ExportPlaintextTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)) + .onAnyDenied(() -> Toast.makeText(getContext(), R.string.ImportExportFragment_signal_needs_the_storage_permission_in_order_to_write_to_external_storage, Toast.LENGTH_LONG).show()) + .execute(); + } else { + new ExportPlaintextTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + }); + builder.setNegativeButton(getActivity().getString(R.string.ExportFragment_cancel), null); + builder.show(); + } + + @SuppressLint("StaticFieldLeak") + private class ImportPlaintextBackupTask extends AsyncTask { + + @Override + protected void onPreExecute() { + progressDialog = ProgressDialog.show(getActivity(), + getActivity().getString(R.string.ImportFragment_importing), + getActivity().getString(R.string.ImportFragment_import_plaintext_backup_elipse), + true, false); + } + + protected void onPostExecute(Integer result) { + Context context = getActivity(); + + if (progressDialog != null) + progressDialog.dismiss(); + + if (context == null) + return; + + switch (result) { + case NO_SD_CARD: + Toast.makeText(context, + context.getString(R.string.ImportFragment_no_plaintext_backup_found), + Toast.LENGTH_LONG).show(); + break; + case ERROR_IO: + Toast.makeText(context, + context.getString(R.string.ImportFragment_error_importing_backup), + Toast.LENGTH_LONG).show(); + break; + case SUCCESS: + Toast.makeText(context, + context.getString(R.string.ImportFragment_import_complete), + Toast.LENGTH_LONG).show(); + break; + } + } + + @Override + protected Integer doInBackground(Void... params) { + try { + PlaintextBackupImporter.importPlaintextFromSd(getActivity()); + return SUCCESS; + } catch (NoExternalStorageException e) { + Log.w(TAG, e); + return NO_SD_CARD; + } catch (IOException e) { + Log.w(TAG, e); + return ERROR_IO; + } + } + } + + @SuppressLint("StaticFieldLeak") + private class ExportPlaintextTask extends AsyncTask { + private ProgressDialog dialog; + + @Override + protected void onPreExecute() { + dialog = ProgressDialog.show(getActivity(), + getActivity().getString(R.string.ExportFragment_exporting), + getActivity().getString(R.string.ExportFragment_exporting_plaintext_to_storage), + true, false); + } + + @Override + protected Integer doInBackground(Void... params) { + try { + PlaintextBackupExporter.exportPlaintextToSd(getActivity()); + return SUCCESS; + } catch (NoExternalStorageException e) { + Log.w(TAG, e); + return NO_SD_CARD; + } catch (IOException e) { + Log.w(TAG, e); + return ERROR_IO; + } + } + + @Override + protected void onPostExecute(Integer result) { + Context context = getActivity(); + + if (dialog != null) + dialog.dismiss(); + + if (context == null) + return; + + switch (result) { + case NO_SD_CARD: + Toast.makeText(context, + context.getString(R.string.ExportFragment_error_unable_to_write_to_storage), + Toast.LENGTH_LONG).show(); + break; + case ERROR_IO: + Toast.makeText(context, + context.getString(R.string.ExportFragment_error_while_writing_to_storage), + Toast.LENGTH_LONG).show(); + break; + case SUCCESS: + Toast.makeText(context, + context.getString(R.string.ExportFragment_export_successful), + Toast.LENGTH_LONG).show(); + break; + } + } + } + + public void handleImportEncryptedBackup() { + CheckAndGetAccessPermissionApi30(); + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setIcon(R.drawable.symbol_error_triangle_fill_24); + builder.setTitle(getActivity().getString(R.string.ImportFragment_restore_encrypted_backup)); + builder.setMessage(getActivity().getString(R.string.ImportFragment_restoring_an_encrypted_backup_will_completely_replace_your_existing_keys)); + builder.setPositiveButton(getActivity().getString(R.string.ImportFragment_restore), (dialog, which) -> { + if (Build.VERSION.SDK_INT >= 30 && SignalStore.settings().getSignalBackupDirectory() == null ) { + BackupDialog.showChooseBackupLocationDialog(this, CHOOSE_FULL_IMPORT_LOCATION_REQUEST_CODE); + } else if (Build.VERSION.SDK_INT < 29) { + Permissions.with(ImportExportFragment.this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.ImportExportFragment_signal_needs_the_storage_permission_in_order_to_read_from_external_storage_but_it_has_been_permanently_denied)) + .onAllGranted(() -> new ImportEncryptedBackupTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)) + .onAnyDenied(() -> Toast.makeText(getContext(), R.string.ImportExportFragment_signal_needs_the_storage_permission_in_order_to_read_from_external_storage, Toast.LENGTH_LONG).show()) + .execute(); + } else { + new ImportEncryptedBackupTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + }); + builder.setNegativeButton(getActivity().getString(R.string.ImportFragment_cancel), null); + builder.show(); + } + + @SuppressLint("StaticFieldLeak") + private class ImportEncryptedBackupTask extends AsyncTask { + + @Override + protected void onPreExecute() { + progressDialog = ProgressDialog.show(getActivity(), + getActivity().getString(R.string.ImportFragment_restoring), + getActivity().getString(R.string.ImportFragment_restoring_encrypted_backup), + true, false); + } + + protected void onPostExecute(Integer result) { + Context context = getActivity(); + + if (progressDialog != null) + progressDialog.dismiss(); + + if (context == null) + return; + + switch (result) { + case NO_SD_CARD: + Toast.makeText(context, + context.getString(R.string.ImportFragment_no_encrypted_backup_found), + Toast.LENGTH_LONG).show(); + break; + case ERROR_IO: + Toast.makeText(context, + context.getString(R.string.ImportFragment_error_importing_backup), + Toast.LENGTH_LONG).show(); + break; + case SUCCESS: + //DatabaseFactory.getInstance(context).reset(context); + //Intent intent = new Intent(context, KeyCachingService.class); + //intent.setAction(KeyCachingService.CLEAR_KEY_ACTION); + //context.startService(intent); + + // JW: Restart after OK press + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setMessage(context.getString(R.string.ImportFragment_restore_complete)) + .setCancelable(false) + .setPositiveButton(context.getString(R.string.ImportFragment_restore_ok), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + ExitActivity.exitAndRemoveFromRecentApps(getActivity()); + } + }); + AlertDialog alert = builder.create(); + alert.show(); + } + } + + @Override + protected Integer doInBackground(Void... params) { + try { + EncryptedBackupExporter.importFromSd(getActivity()); + return SUCCESS; + } catch (NoExternalStorageException e) { + Log.w(TAG, e); + return NO_SD_CARD; + } catch (IOException e) { + Log.w(TAG, e); + return ERROR_IO; + } + } + } + + private void handleExportEncryptedBackup() { + CheckAndGetAccessPermissionApi30(); + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setIcon(R.drawable.symbol_info_24); + builder.setTitle(getActivity().getString(R.string.ExportFragment_export_to_sd_card)); + builder.setMessage(getActivity().getString(R.string.ExportFragment_this_will_export_your_encrypted_keys_settings_and_messages)); + builder.setPositiveButton(getActivity().getString(R.string.ExportFragment_export), (dialog, which) -> { + if (Build.VERSION.SDK_INT >= 30 && SignalStore.settings().getSignalBackupDirectory() == null ) { + BackupDialog.showChooseBackupLocationDialog(this, CHOOSE_FULL_EXPORT_LOCATION_REQUEST_CODE); + } else if (Build.VERSION.SDK_INT < 29) { + Permissions.with(ImportExportFragment.this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE) + .ifNecessary() + .withPermanentDenialDialog(getString(R.string.ImportExportFragment_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) + .onAllGranted(() -> new ExportEncryptedTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR)) + .onAnyDenied(() -> Toast.makeText(getContext(), R.string.ImportExportFragment_signal_needs_the_storage_permission_in_order_to_write_to_external_storage, Toast.LENGTH_LONG).show()) + .execute(); + } else { + new ExportEncryptedTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + }); + builder.setNegativeButton(getActivity().getString(R.string.ExportFragment_cancel), null); + builder.show(); + } + + private class ExportEncryptedTask extends AsyncTask { + private ProgressDialog dialog; + + @Override + protected void onPreExecute() { + dialog = ProgressDialog.show(getActivity(), + getActivity().getString(R.string.ExportFragment_exporting), + getActivity().getString(R.string.ExportFragment_exporting_keys_settings_and_messages), + true, false); + } + + @Override + protected void onPostExecute(Integer result) { + Context context = getActivity(); + + if (dialog != null) dialog.dismiss(); + + if (context == null) return; + + switch (result) { + case NO_SD_CARD: + Toast.makeText(context, + context.getString(R.string.ExportFragment_error_unable_to_write_to_storage), + Toast.LENGTH_LONG).show(); + break; + case ERROR_IO: + Toast.makeText(context, + context.getString(R.string.ExportFragment_error_while_writing_to_storage), + Toast.LENGTH_LONG).show(); + break; + case SUCCESS: + Toast.makeText(context, + context.getString(R.string.ExportFragment_export_successful), + Toast.LENGTH_LONG).show(); + break; + } + } + + @Override + protected Integer doInBackground(Void... params) { + try { + EncryptedBackupExporter.exportToSd(getActivity()); + return SUCCESS; + } catch (NoExternalStorageException e) { + Log.w(TAG, e); + return NO_SD_CARD; + } catch (IOException e) { + Log.w(TAG, e); + return ERROR_IO; + } + } + } + + private boolean existsWhatsAppMessageDatabase() { + String dbfile = Environment.getExternalStorageDirectory().getAbsolutePath() + File.separator + "msgstore.db"; + File msgdb = new File(dbfile); + if (msgdb.exists()) return true; else return false; + } + + private void CheckAndGetAccessPermissionApi30() { + if (Build.VERSION.SDK_INT >= 30) { + if (!Environment.isExternalStorageManager()) { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setMessage(getActivity().getString(R.string.ImportExportFragment_signal_needs_the_all_files_access_permission)) + .setCancelable(false) + .setPositiveButton(getActivity().getString(R.string.ImportFragment_restore_ok), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + Intent intent = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION); + startActivity(intent); + } + }); + AlertDialog alert = builder.create(); + alert.show(); + } + } + } + + @Override + public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (data != null && data.getData() != null) { + Uri backupDirectoryUri = data.getData(); + SignalStore.settings().setSignalBackupDirectory(backupDirectoryUri); + String backupDir = UriUtils.getFullPathFromTreeUri(getActivity(), backupDirectoryUri); + int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + + Log.w(TAG, "Take permission from Uri: " + backupDir); + getActivity().getContentResolver().takePersistableUriPermission(backupDirectoryUri, takeFlags); + + if (resultCode == Activity.RESULT_OK) { + switch (requestCode) { + case CHOOSE_PLAINTEXT_IMPORT_LOCATION_REQUEST_CODE: + new ImportPlaintextBackupTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + break; + case CHOOSE_PLAINTEXT_EXPORT_LOCATION_REQUEST_CODE: + new ExportPlaintextTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + break; + case CHOOSE_FULL_IMPORT_LOCATION_REQUEST_CODE: + new ImportEncryptedBackupTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + break; + case CHOOSE_FULL_EXPORT_LOCATION_REQUEST_CODE: + new ExportEncryptedTask().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + break; + } + } + } + } + +} + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ImportWhatsappDialog.java b/app/src/main/java/org/thoughtcrime/securesms/ImportWhatsappDialog.java new file mode 100644 index 00000000000..d7bbbace710 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/ImportWhatsappDialog.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.view.View; +import android.widget.CheckBox; + +import androidx.appcompat.app.AlertDialog; + +public class ImportWhatsappDialog { + + private static boolean importGroups = false; + private static boolean avoidDuplicates = false; + private static boolean importMedia = false; + + @SuppressWarnings("CodeBlock2Expr") + @SuppressLint("InlinedApi") + public static AlertDialog.Builder getWhatsappBackupDialog(Activity activity) { + View checkBoxView = View.inflate(activity, R.layout.dialog_import_whatsapp, null); + CheckBox importGroupsCheckbox = checkBoxView.findViewById(R.id.import_groups_checkbox); + importGroupsCheckbox.setChecked(importGroups); + importGroupsCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> { + ImportWhatsappDialog.importGroups = isChecked; + }); + + CheckBox avoidDuplicatesCheckbox = checkBoxView.findViewById(R.id.avoid_duplicates_checkbox); + avoidDuplicatesCheckbox.setChecked(avoidDuplicates); + avoidDuplicatesCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> { + ImportWhatsappDialog.avoidDuplicates = isChecked; + }); + + CheckBox importMediaCheckbox = checkBoxView.findViewById(R.id.import_media_checkbox); + importMediaCheckbox.setChecked(importMedia); + importMediaCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> { + ImportWhatsappDialog.importMedia = isChecked; + }); + + AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setIcon(R.drawable.symbol_error_triangle_fill_24); + builder.setTitle(activity.getString(R.string.ImportFragment_import_whatsapp_backup)); + builder.setMessage(activity.getString(R.string.ImportFragment_this_will_import_messages_from_whatsapp_backup)) + .setView(checkBoxView); + return builder; + } + + public static boolean isImportGroups() { + return importGroups; + } + + public static boolean isAvoidDuplicates() { + return avoidDuplicates; + } + + public static boolean isImportMedia() { + return importMedia; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java index de3387945c5..0616a51df43 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphrasePromptActivity.java @@ -33,6 +33,7 @@ import android.view.MenuItem; import android.view.View; import android.view.View.OnClickListener; +import android.view.WindowManager; // JW: added import android.view.inputmethod.EditorInfo; import android.widget.EditText; import android.widget.ImageButton; @@ -271,10 +272,13 @@ private void initializeResources() { private void setLockTypeVisibility() { if (SignalStore.settings().getScreenLockEnabled()) { passphraseAuthContainer.setVisibility(View.GONE); + getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); // JW: to override setSoftInputMode it has to be defined in the manifest file unlockView.setVisibility(View.VISIBLE); lockScreenButton.setVisibility(View.VISIBLE); } else { passphraseAuthContainer.setVisibility(View.VISIBLE); + getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); // JW + passphraseText.requestFocus(); // JW unlockView.setVisibility(View.GONE); lockScreenButton.setVisibility(View.GONE); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/location/SignalMapView.java b/app/src/main/java/org/thoughtcrime/securesms/components/location/SignalMapView.java index 00156d5c861..53f76639d4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/location/SignalMapView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/location/SignalMapView.java @@ -20,6 +20,7 @@ import org.signal.core.util.concurrent.ListenableFuture; import org.signal.core.util.concurrent.SettableFuture; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.TextSecurePreferences; // JW: added import java.util.concurrent.ExecutionException; @@ -28,6 +29,7 @@ public class SignalMapView extends LinearLayout { private MapView mapView; private ImageView imageView; private TextView textView; + public static int mapType; // JW public SignalMapView(Context context) { this(context, null); @@ -50,6 +52,18 @@ private void initialize(Context context) { this.mapView = findViewById(R.id.map_view); this.imageView = findViewById(R.id.image_view); this.textView = findViewById(R.id.address_view); + this.mapType = getGoogleMapType(context); // JW + } + + // JW: get the maptype + private int getGoogleMapType(Context context) { + switch (TextSecurePreferences.getGoogleMapType(context)) { + case "hybrid": return GoogleMap.MAP_TYPE_HYBRID; + case "satellite": return GoogleMap.MAP_TYPE_SATELLITE; + case "terrain": return GoogleMap.MAP_TYPE_TERRAIN; + case "none": return GoogleMap.MAP_TYPE_NONE; + default: return GoogleMap.MAP_TYPE_NORMAL; + } } public ListenableFuture display(final SignalPlace place) { @@ -86,7 +100,7 @@ public static ListenableFuture snapshot(final LatLng place, @NonNull fin googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(place, 13)); googleMap.addMarker(new MarkerOptions().position(place)); googleMap.setBuildingsEnabled(true); - googleMap.setMapType(GoogleMap.MAP_TYPE_NORMAL); + googleMap.setMapType(mapType); // JW: set maptype googleMap.getUiSettings().setAllGesturesEnabled(false); googleMap.setOnMapLoadedCallback(() -> googleMap.snapshot(bitmap -> { future.set(bitmap); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt index 1678d5fc894..44e45bb001d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt @@ -1,19 +1,32 @@ package org.thoughtcrime.securesms.components.settings.app.chats +import android.app.Activity // JW: added +import android.content.Intent // JW: added +import android.os.Build // JW: added import androidx.lifecycle.ViewModelProvider import androidx.navigation.Navigation import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.BackupDialog // JW: added import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.service.LocalBackupListener // JW: added +import org.thoughtcrime.securesms.util.TextSecurePreferences // JW: added +import org.thoughtcrime.securesms.keyvalue.SignalStore // JW: added import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.thoughtcrime.securesms.util.UriUtils // JW: added class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__chats) { private lateinit var viewModel: ChatsSettingsViewModel + private val mapLabels by lazy { resources.getStringArray(R.array.pref_map_type_entries) } // JW: added + private val mapValues by lazy { resources.getStringArray(R.array.pref_map_type_values) } // JW: added + private val groupAddLabels by lazy { resources.getStringArray(R.array.pref_group_add_entries) } // JW: added + private val groupAddValues by lazy { resources.getStringArray(R.array.pref_group_add_values) } // JW: added + val CHOOSE_BACKUPS_LOCATION_REQUEST_CODE = 1201 // JW: added override fun onResume() { super.onResume() @@ -29,6 +42,25 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch } } + // JW: added + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { + super.onActivityResult(requestCode, resultCode, intent) + + if (intent != null && intent.data != null) { + if (resultCode == Activity.RESULT_OK) { + if (requestCode == CHOOSE_BACKUPS_LOCATION_REQUEST_CODE) { + val backupUri = intent.data + val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + SignalStore.settings.setSignalBackupDirectory(backupUri!!) + context?.getContentResolver()?.takePersistableUriPermission(backupUri, takeFlags) + TextSecurePreferences.setNextBackupTime(requireContext(), 0) + LocalBackupListener.schedule(context) + viewModel.setChatBackupLocationApi30(UriUtils.getFullPathFromTreeUri(context, backupUri)) + } + } + } + } + private fun getConfiguration(state: ChatsSettingsState): DSLConfiguration { return configure { switchPref( @@ -91,7 +123,8 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch } ) - if (!RemoteConfig.messageBackups) { + //if (!RemoteConfig.messageBackups) { + if (true) { // JW: always enable local backups dividerPref() sectionHeaderPref(R.string.preferences_chats__backups) @@ -103,6 +136,112 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_backupsPreferenceFragment) } ) + + // JW: added + if (Build.VERSION.SDK_INT < 30) { + switchPref( + title = DSLSettingsText.from(R.string.preferences_chats__chat_backups_removable), + summary = DSLSettingsText.from(R.string.preferences_chats__backup_chats_to_removable_storage), + isChecked = state.chatBackupsLocation, + onClick = { + viewModel.setChatBackupLocation(!state.chatBackupsLocation) + } + ) + } else { + val backupUri = SignalStore.settings.signalBackupDirectory + val summaryText = UriUtils.getFullPathFromTreeUri(context, backupUri) + + clickPref( + title = DSLSettingsText.from(R.string.preferences_chats__chat_backups_location_tap_to_change), + summary = DSLSettingsText.from(summaryText), + onClick = { + BackupDialog.showChooseBackupLocationDialog(this@ChatsSettingsFragment, CHOOSE_BACKUPS_LOCATION_REQUEST_CODE) + viewModel.setChatBackupLocationApi30(UriUtils.getFullPathFromTreeUri(context, backupUri)) + } + ) + } + + // JW: added + switchPref( + title = DSLSettingsText.from(R.string.preferences_chats__chat_backups_zipfile), + summary = DSLSettingsText.from(R.string.preferences_chats__backup_chats_to_encrypted_zipfile), + isChecked = state.chatBackupZipfile, + onClick = { + viewModel.setChatBackupZipfile(!state.chatBackupZipfile) + } + ) + + // JW: added + switchPref( + title = DSLSettingsText.from(R.string.preferences_chats__chat_backups_zipfile_plain), + summary = DSLSettingsText.from(R.string.preferences_chats__backup_chats_to_encrypted_zipfile_plain), + isChecked = state.chatBackupZipfilePlain, + onClick = { + viewModel.setChatBackupZipfilePlain(!state.chatBackupZipfilePlain) + } + ) + + dividerPref() + + sectionHeaderPref(R.string.preferences_chats__control_message_deletion) + + // JW: added + switchPref( + title = DSLSettingsText.from(R.string.preferences_chats__chat_keep_view_once_messages), + summary = DSLSettingsText.from(R.string.preferences_chats__keep_view_once_messages_summary), + isChecked = state.keepViewOnceMessages, + onClick = { + viewModel.keepViewOnceMessages(!state.keepViewOnceMessages) + } + ) + + // JW: added + switchPref( + title = DSLSettingsText.from(R.string.preferences_chats__chat_ignore_remote_delete), + summary = DSLSettingsText.from(R.string.preferences_chats__chat_ignore_remote_delete_summary), + isChecked = state.ignoreRemoteDelete, + onClick = { + viewModel.ignoreRemoteDelete(!state.ignoreRemoteDelete) + } + ) + + // JW: added + switchPref( + title = DSLSettingsText.from(R.string.preferences_chats__delete_media_only), + summary = DSLSettingsText.from(R.string.preferences_chats__delete_media_only_summary), + isChecked = state.deleteMediaOnly, + onClick = { + viewModel.deleteMediaOnly(!state.deleteMediaOnly) + } + ) + + dividerPref() + + sectionHeaderPref(R.string.preferences_chats__group_control) + + // JW: added + radioListPref( + title = DSLSettingsText.from(R.string.preferences_chats__who_can_add_you_to_groups), + listItems = groupAddLabels, + selected = groupAddValues.indexOf(state.whoCanAddYouToGroups), + onSelected = { + viewModel.setWhoCanAddYouToGroups(groupAddValues[it]) + } + ) + + dividerPref() + + sectionHeaderPref(R.string.preferences_chats__google_map_type) + + // JW: added + radioListPref( + title = DSLSettingsText.from(R.string.preferences__map_type), + listItems = mapLabels, + selected = mapValues.indexOf(state.googleMapType), + onSelected = { + viewModel.setGoogleMapType(mapValues[it]) + } + ) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsState.kt index ed174592c20..4d478278347 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsState.kt @@ -7,4 +7,15 @@ data class ChatsSettingsState( val useSystemEmoji: Boolean, val enterKeySends: Boolean, val localBackupsEnabled: Boolean + // JW: added extra preferences + , + val chatBackupsLocation: Boolean, + val chatBackupsLocationApi30: String, + val chatBackupZipfile: Boolean, + val chatBackupZipfilePlain: Boolean, + val keepViewOnceMessages: Boolean, + val ignoreRemoteDelete: Boolean, + val deleteMediaOnly: Boolean, + val googleMapType: String, + val whoCanAddYouToGroups: String ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt index f7267380637..0dd02e2ce4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt @@ -6,7 +6,9 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.BackupUtil import org.thoughtcrime.securesms.util.ConversationUtil +import org.thoughtcrime.securesms.util.TextSecurePreferences // JW: added import org.thoughtcrime.securesms.util.ThrottledDebouncer +import org.thoughtcrime.securesms.util.UriUtils // JW: added import org.thoughtcrime.securesms.util.livedata.Store class ChatsSettingsViewModel @JvmOverloads constructor( @@ -23,6 +25,17 @@ class ChatsSettingsViewModel @JvmOverloads constructor( useSystemEmoji = SignalStore.settings.isPreferSystemEmoji, enterKeySends = SignalStore.settings.isEnterKeySends, localBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(AppDependencies.application) + // JW: added + , + chatBackupsLocation = TextSecurePreferences.isBackupLocationRemovable(AppDependencies.application), + chatBackupsLocationApi30 = UriUtils.getFullPathFromTreeUri(AppDependencies.application, SignalStore.settings.signalBackupDirectory), + chatBackupZipfile = TextSecurePreferences.isRawBackupInZipfile(AppDependencies.application), + chatBackupZipfilePlain = TextSecurePreferences.isPlainBackupInZipfile(AppDependencies.application), + keepViewOnceMessages = TextSecurePreferences.isKeepViewOnceMessages(AppDependencies.application), + ignoreRemoteDelete = TextSecurePreferences.isIgnoreRemoteDelete(AppDependencies.application), + deleteMediaOnly = TextSecurePreferences.isDeleteMediaOnly(AppDependencies.application), + googleMapType = TextSecurePreferences.getGoogleMapType(AppDependencies.application), + whoCanAddYouToGroups = TextSecurePreferences.whoCanAddYouToGroups(AppDependencies.application) ) ) @@ -64,5 +77,80 @@ class ChatsSettingsViewModel @JvmOverloads constructor( ) { store.update { it.copy(localBackupsEnabled = backupsEnabled) } } + // JW: added. This is required to update the UI for settings that are not in the Signal store but in the shared preferences. + store.update { getState().copy() } } + + // JW: added + fun setChatBackupLocation(enabled: Boolean) { + TextSecurePreferences.setBackupLocationRemovable(AppDependencies.application, enabled) + TextSecurePreferences.setBackupLocationChanged(AppDependencies.application, true) // Used in BackupUtil.getAllBackupsNewestFirst() + refresh() + } + + // JW: added + fun setChatBackupLocationApi30(value: String) { + refresh() + } + + // JW: added + fun setChatBackupZipfile(enabled: Boolean) { + TextSecurePreferences.setRawBackupZipfile(AppDependencies.application, enabled) + refresh() + } + + // JW: added + fun setChatBackupZipfilePlain(enabled: Boolean) { + TextSecurePreferences.setPlainBackupZipfile(AppDependencies.application, enabled) + refresh() + } + + // JW: added + fun keepViewOnceMessages(enabled: Boolean) { + TextSecurePreferences.setKeepViewOnceMessages(AppDependencies.application, enabled) + refresh() + } + + // JW: added + fun ignoreRemoteDelete(enabled: Boolean) { + TextSecurePreferences.setIgnoreRemoteDelete(AppDependencies.application, enabled) + refresh() + } + + // JW: added + fun deleteMediaOnly(enabled: Boolean) { + TextSecurePreferences.setDeleteMediaOnly(AppDependencies.application, enabled) + refresh() + } + + // JW: added + fun setGoogleMapType(mapType: String) { + TextSecurePreferences.setGoogleMapType(AppDependencies.application, mapType) + refresh() + } + + // JW: added + fun setWhoCanAddYouToGroups(adder: String) { + TextSecurePreferences.setWhoCanAddYouToGroups(AppDependencies.application, adder) + refresh() + } + + // JW: added + private fun getState() = ChatsSettingsState( + generateLinkPreviews = SignalStore.settings.isLinkPreviewsEnabled, + useAddressBook = SignalStore.settings.isPreferSystemContactPhotos, + keepMutedChatsArchived = SignalStore.settings.shouldKeepMutedChatsArchived(), + useSystemEmoji = SignalStore.settings.isPreferSystemEmoji, + enterKeySends = SignalStore.settings.isEnterKeySends, + localBackupsEnabled = SignalStore.settings.isBackupEnabled, + chatBackupsLocationApi30 = UriUtils.getFullPathFromTreeUri(AppDependencies.application, SignalStore.settings.signalBackupDirectory), + chatBackupsLocation = TextSecurePreferences.isBackupLocationRemovable(AppDependencies.application), + chatBackupZipfile = TextSecurePreferences.isRawBackupInZipfile(AppDependencies.application), + chatBackupZipfilePlain = TextSecurePreferences.isPlainBackupInZipfile(AppDependencies.application), + keepViewOnceMessages = TextSecurePreferences.isKeepViewOnceMessages(AppDependencies.application), + ignoreRemoteDelete = TextSecurePreferences.isIgnoreRemoteDelete(AppDependencies.application), + deleteMediaOnly = TextSecurePreferences.isDeleteMediaOnly(AppDependencies.application), + googleMapType = TextSecurePreferences.getGoogleMapType(AppDependencies.application), + whoCanAddYouToGroups = TextSecurePreferences.whoCanAddYouToGroups(AppDependencies.application) + ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt index d45ccde7e38..abd0138f9a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsFragment.kt @@ -182,30 +182,62 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac sectionHeaderPref(R.string.PrivacySettingsFragment__app_security) - if (state.isObsoletePasswordEnabled) { + // JW: added toggle between password and Android screenlock + switchPref( + title = DSLSettingsText.from(R.string.preferences_app_protection__method_passphrase), + summary = DSLSettingsText.from(R.string.preferences_app_protection__method_passphrase_summary), + isChecked = state.isProtectionMethodPassphrase, + onClick = { + // After a togggle, we disable both passphrase and Android keylock. + // Remove the passphrase if there is one set + if (state.isObsoletePasswordEnabled) { + MasterSecretUtil.changeMasterSecretPassphrase( + activity, + KeyCachingService.getMasterSecret(context), + MasterSecretUtil.UNENCRYPTED_PASSPHRASE + ) + SignalStore.settings.setPassphraseDisabled(true) + val intent = Intent(context, KeyCachingService::class.java) + intent.action = KeyCachingService.DISABLE_ACTION + requireActivity().startService(intent) + } + TextSecurePreferences.setProtectionMethod(activity, !state.isProtectionMethodPassphrase) + viewModel.setNoLock() + } + ) + + //if (state.isObsoletePasswordEnabled) { + if (viewModel.isPassphraseSelected()) { // JW: method changed switchPref( title = DSLSettingsText.from(R.string.preferences__enable_passphrase), summary = DSLSettingsText.from(R.string.preferences__lock_signal_and_message_notifications_with_a_passphrase), - isChecked = true, + isChecked = state.isObsoletePasswordEnabled, // JW onClick = { - MaterialAlertDialogBuilder(requireContext()).apply { - setTitle(R.string.ApplicationPreferencesActivity_disable_passphrase) - setMessage(R.string.ApplicationPreferencesActivity_this_will_permanently_unlock_signal_and_message_notifications) - setIcon(R.drawable.symbol_error_triangle_fill_24) - setPositiveButton(R.string.ApplicationPreferencesActivity_disable) { _, _ -> - MasterSecretUtil.changeMasterSecretPassphrase( - activity, - KeyCachingService.getMasterSecret(context), - MasterSecretUtil.UNENCRYPTED_PASSPHRASE - ) - SignalStore.settings.passphraseDisabled = true - val intent = Intent(activity, KeyCachingService::class.java) - intent.action = KeyCachingService.DISABLE_ACTION - requireActivity().startService(intent) - viewModel.refresh() + if (state.isObsoletePasswordEnabled) { // JW: added if else + MaterialAlertDialogBuilder(requireContext()).apply { + setTitle(R.string.ApplicationPreferencesActivity_disable_passphrase) + setMessage(R.string.ApplicationPreferencesActivity_this_will_permanently_unlock_signal_and_message_notifications) + setIcon(R.drawable.symbol_error_triangle_fill_24) + setPositiveButton(R.string.ApplicationPreferencesActivity_disable) { _, _ -> + MasterSecretUtil.changeMasterSecretPassphrase( + activity, + KeyCachingService.getMasterSecret(context), + MasterSecretUtil.UNENCRYPTED_PASSPHRASE + ) + SignalStore.settings.setPassphraseDisabled(true) + val intent = Intent(activity, KeyCachingService::class.java) + intent.action = KeyCachingService.DISABLE_ACTION + requireActivity().startService(intent) + viewModel.refresh() + } + setNegativeButton(android.R.string.cancel, null) + show() } - setNegativeButton(android.R.string.cancel, null) - show() + } else { + // enable password + val intent = Intent(activity, PassphraseChangeActivity::class.java) + startActivity(intent) + viewModel.refresh() } } ) @@ -237,6 +269,7 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac clickPref( title = DSLSettingsText.from(R.string.preferences__inactivity_timeout_interval), + summary = DSLSettingsText.from(getScreenLockInactivityTimeoutSummary(state.isObsoletePasswordTimeoutEnabled,60 * state.obsoletePasswordTimeout.toLong())), // JW onClick = { childFragmentManager.clearFragmentResult(TimeDurationPickerDialog.RESULT_DURATION) childFragmentManager.clearFragmentResultListener(TimeDurationPickerDialog.RESULT_DURATION) @@ -244,7 +277,7 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac val timeout = bundle.getLong(TimeDurationPickerDialog.RESULT_KEY_DURATION_MILLISECONDS).milliseconds.inWholeMinutes.toInt() viewModel.setObsoletePasswordTimeout(max(timeout, 1)) } - TimeDurationPickerDialog.create(state.screenLockActivityTimeout.seconds).show(childFragmentManager, null) + TimeDurationPickerDialog.create(state.obsoletePasswordTimeout.seconds * 60).show(childFragmentManager, null) // JW } ) } else { @@ -258,6 +291,7 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac }, isEnabled = isKeyguardSecure, onDisabledClicked = { + viewModel.setOnlyScreenlockEnabled(!state.screenLock) // JW: changed Snackbar .make( requireView(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt index 06c314db15f..08c7815ea6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsState.kt @@ -13,4 +13,7 @@ data class PrivacySettingsState( val isObsoletePasswordTimeoutEnabled: Boolean, val obsoletePasswordTimeout: Int, val universalExpireTimer: Int + // JW: added + , + val isProtectionMethodPassphrase: Boolean ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt index 408c988a4b4..d7b9a197d07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/PrivacySettingsViewModel.kt @@ -62,6 +62,40 @@ class PrivacySettingsViewModel( refresh() } + // JW: added + fun setPassphraseEnabled(enabled: Boolean) { + SignalStore.settings.setPassphraseDisabled(!enabled) + SignalStore.settings.setScreenLockEnabled(!enabled) + sharedPreferences.edit().putBoolean("pref_enable_passphrase_temporary", enabled).apply() + refresh() + } + + // JW: added + fun setOnlyScreenlockEnabled(enabled: Boolean) { + SignalStore.settings.setPassphraseDisabled(true) + SignalStore.settings.setScreenLockEnabled(enabled) + sharedPreferences.edit().putBoolean("pref_enable_passphrase_temporary", false).apply() + refresh() + } + + // JW: added + fun setNoLock() { + SignalStore.settings.setPassphraseDisabled(true) + SignalStore.settings.setScreenLockEnabled(false) + sharedPreferences.edit().putBoolean("pref_enable_passphrase_temporary", false).apply() + refresh() + } + + // JW: added method. + fun isPassphraseSelected(): Boolean { + // Because this preference may be undefined when this app is first ran we also check if there is a passphrase + // defined, if so, we assume passphrase protection: + val myContext = AppDependencies.application + + return TextSecurePreferences.isProtectionMethodPassphrase(myContext) || + TextSecurePreferences.getBooleanPreference(myContext, "pref_enable_passphrase_temporary", false) && !SignalStore.settings.getPassphraseDisabled() + } + fun refresh() { store.update(this::updateState) } @@ -80,6 +114,9 @@ class PrivacySettingsViewModel( isObsoletePasswordTimeoutEnabled = SignalStore.settings.passphraseTimeoutEnabled, obsoletePasswordTimeout = SignalStore.settings.passphraseTimeout, universalExpireTimer = SignalStore.settings.universalExpireTimer + // JW: added + , + isProtectionMethodPassphrase = TextSecurePreferences.isProtectionMethodPassphrase(AppDependencies.application) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsFragment.kt index 9d0dd5052d9..d7b02148880 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsFragment.kt @@ -110,6 +110,17 @@ class AdvancedPrivacySettingsFragment : DSLSettingsFragment(R.string.preferences viewModel.setAlwaysRelayCalls(!state.alwaysRelayCalls) } + // JW: added + switchPref( + title = DSLSettingsText.from(R.string.preferences_advanced__push_notifications_fcm), + summary = DSLSettingsText.from(R.string.preferences_advanced__push_notifications_fcm_summary), + isChecked = state.pushNotificationsViaFCM + ) { + val isFcm = state.pushNotificationsViaFCM + viewModel.setPushNotificationsViaFCM(!isFcm) + FCMPreferenceFunctions.onFCMPreferenceChange(context, !isFcm) + } + dividerPref() sectionHeaderPref(R.string.preferences_communication__category_censorship_circumvention) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsState.kt index 3f5692d2b72..151da675f8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsState.kt @@ -8,6 +8,9 @@ data class AdvancedPrivacySettingsState( val showSealedSenderStatusIcon: Boolean, val allowSealedSenderFromAnyone: Boolean, val showProgressSpinner: Boolean + // JW: added + , + val pushNotificationsViaFCM: Boolean ) enum class CensorshipCircumventionState(val available: Boolean) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsViewModel.kt index 9dff6b96457..352a6e64738 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/AdvancedPrivacySettingsViewModel.kt @@ -62,6 +62,12 @@ class AdvancedPrivacySettingsViewModel( refresh() } + // JW: added + fun setPushNotificationsViaFCM(enabled: Boolean) { + SignalStore.account.fcmEnabled = enabled + refresh() + } + fun refresh() { store.update { getState().copy(showProgressSpinner = it.showProgressSpinner) } } @@ -85,6 +91,9 @@ class AdvancedPrivacySettingsViewModel( AppDependencies.application ), false + // JW: added + , + pushNotificationsViaFCM = SignalStore.account.fcmEnabled ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/FCMPreferenceFunctions.java b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/FCMPreferenceFunctions.java new file mode 100644 index 00000000000..35c20427671 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/privacy/advanced/FCMPreferenceFunctions.java @@ -0,0 +1,127 @@ +package org.thoughtcrime.securesms.components.settings.app.privacy.advanced; + +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.dependencies.AppDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.whispersystems.signalservice.api.SignalServiceAccountManager; + +import java.io.IOException; +import java.util.Optional; + +//----------------------------------------------------------------------------- +// JW: added +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.pm.PackageManager; +import org.thoughtcrime.securesms.ExitActivity; +import org.thoughtcrime.securesms.jobs.FcmRefreshJob; +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; +import org.thoughtcrime.securesms.messages.IncomingMessageObserver; +import org.thoughtcrime.securesms.util.PlayServicesUtil; +//----------------------------------------------------------------------------- + +public class FCMPreferenceFunctions { + private static final String TAG = Log.tag(FCMPreferenceFunctions.class); + + public static void cleanGcmId(Context context) { + try { + SignalServiceAccountManager accountManager = AppDependencies.getSignalServiceAccountManager(); + accountManager.setGcmId(Optional.empty()); + } catch (IOException e) { + Log.w(TAG, e.getMessage()); + Toast.makeText(context, R.string.ApplicationPreferencesActivity_error_connecting_to_server, Toast.LENGTH_LONG).show(); + } catch (Exception e) { + Log.w(TAG, e.getMessage()); + Toast.makeText(context, "Exception: " + e.getMessage(), Toast.LENGTH_LONG).show(); + } + } + + public static void exitAndRestart(Context context) { + // JW: Restart after OK press + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setMessage(context.getString(R.string.preferences_advanced__need_to_restart)) + .setCancelable(false) + .setPositiveButton(context.getString(R.string.ImportFragment_restore_ok), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + restartApp(context); + } + }); + AlertDialog alert = builder.create(); + alert.show(); + } + + // Create a pending intent to restart Signal + public static void restartApp(Context context) { + try { + if (context != null) { + PackageManager pm = context.getPackageManager(); + + if (pm != null) { + Intent startActivity = pm.getLaunchIntentForPackage(context.getPackageName()); + if (startActivity != null) { + startActivity.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + int pendingIntentId = 223344; + PendingIntent pendingIntent = PendingIntent.getActivity(context, pendingIntentId, startActivity, PendingIntent.FLAG_CANCEL_CURRENT); + AlarmManager mgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + mgr.set(AlarmManager.RTC, System.currentTimeMillis() + 100, pendingIntent); + System.exit(0); + //ExitActivity.exitAndRemoveFromRecentApps(getActivity()); + } else { + Log.e(TAG, "restartApp: unable to restart application, startActivity == null"); + System.exit(0); + } + } else { + Log.e(TAG, "restartApp: unable to restart application, Package manager == null"); + System.exit(0); + } + } else { + Log.e(TAG, "restartApp: unable to restart application, Context == null"); + System.exit(0); + } + } catch (Exception e) { + Log.e(TAG, "restartApp: unable to restart application: " + e.getMessage()); + System.exit(0); + } + } + + public static void onFCMPreferenceChange(Context context, boolean isFcm) { + if (isFcm) { + PlayServicesUtil.PlayServicesStatus status = PlayServicesUtil.getPlayServicesStatus(context); + + if (status == PlayServicesUtil.PlayServicesStatus.SUCCESS) { + SignalStore.account().setFcmEnabled(true); + //TextSecurePreferences.setWebsocketRegistered(context, false); + Toast.makeText(context, "Setting setFcmDisabled to false", Toast.LENGTH_LONG).show(); + AppDependencies.getJobManager().startChain(new FcmRefreshJob()) + .then(new RefreshAttributesJob()) + .enqueue(); + + context.stopService(new Intent(context, IncomingMessageObserver.ForegroundService.class)); + + Log.i(TAG, "onFCMPreferenceChange: enabled fcm"); + exitAndRestart(context); + } else { + // No Play Services found + Toast.makeText(context, R.string.preferences_advanced__play_services_not_found, Toast.LENGTH_LONG).show(); + } + } else { // switch to websockets + SignalStore.account().setFcmEnabled(false); + //TextSecurePreferences.setWebsocketRegistered(context, true); + SignalStore.account().setFcmToken(null); + cleanGcmId(context); + AppDependencies.getJobManager().add(new RefreshAttributesJob()); + Log.i(TAG, "onFCMPreferenceChange: disabled fcm"); + Toast.makeText(context, "Switching to Websockets", Toast.LENGTH_LONG).show(); + exitAndRestart(context); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 8bcdd683af7..923a98f7328 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -84,6 +84,7 @@ import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.ImportExportActivity; // JW import org.thoughtcrime.securesms.MainActivity; import org.thoughtcrime.securesms.MainFragment; import org.thoughtcrime.securesms.MainNavigator; @@ -613,11 +614,17 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { } else if (itemId == R.id.menu_clear_unread_filter) { onClearFilterClick(); return true; + } else if (itemId == R.id.menu_import_export) { handleImportExport(); return true; // JW: added } else { return false; } } + // JW: added + private void handleImportExport() { + startActivity(new Intent(requireActivity(), ImportExportActivity.class)); + } + @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/EncryptedBackupExporter.java b/app/src/main/java/org/thoughtcrime/securesms/database/EncryptedBackupExporter.java new file mode 100644 index 00000000000..82c893d6576 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/EncryptedBackupExporter.java @@ -0,0 +1,508 @@ +/** + * Copyright (C) 2011 Whisper Systems + * + * 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 3 of the License, or + * (at your option) any later version. + * + * 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.thoughtcrime.securesms.database; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Environment; + +import androidx.annotation.NonNull; + +import org.signal.core.util.Base64; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.crypto.AttachmentSecret; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.backup.BackupPassphrase; +import org.thoughtcrime.securesms.crypto.DatabaseSecret; +import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider; +import org.thoughtcrime.securesms.crypto.KeyStoreHelper; +import org.thoughtcrime.securesms.util.FileUtilsJW; +import org.thoughtcrime.securesms.util.JsonUtils; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.lang.String; +import java.nio.channels.FileChannel; +import java.security.SecureRandom; +import java.util.Map; +import java.util.Set; + +public class EncryptedBackupExporter { + + private static final String TAG = Log.tag(EncryptedBackupExporter.class); + + // Files used to store the DatabaseSecret, AttachmentSecret and LogSecret, required after the transfer to SQLCipher in Signal 4.16 + private static final String databaseSecretFile = "databasesecret.txt"; + private static final String attachmentSecretFile = "attachmentsecret.txt"; + private static final String logSecretFile = "logsecret.txt"; + private static final String backupKeyFile = "backupkey.txt"; + private static final String exportDirectory = "SignalExport"; + private static final String secretsExportDirectory = "SignalSecrets"; + + public static void exportToSd(Context context) throws NoExternalStorageException, IOException { + verifyExternalStorageForExport(context); + AttachmentSecretProvider asp = AttachmentSecretProvider.getInstance(context); + DatabaseSecret dbs = DatabaseSecretProvider.getOrCreateDatabaseSecret(context); + AttachmentSecret ats = asp.getOrCreateAttachmentSecret(); + byte[] lgs = getOrCreateLogSecret(context); + String bks = BackupPassphrase.get(context); + exportDirectory(context, ""); + exportSecrets(context, dbs, ats, lgs, bks); + if (TextSecurePreferences.isRawBackupInZipfile(context)) { + File test = new File(getEncryptedZipfileName()); + if (test.exists()) { + test.delete(); + } + FileUtilsJW.createEncryptedZipfile(context, getEncryptedZipfileName(), getExportDirectoryPath(context), getExportSecretsDirectory(context)); + deleteRawBackupFiles(context); + } + } + + public static void importFromSd(Context context) throws NoExternalStorageException, IOException { + // Store in a boolean because settings might change after restore + boolean rawBackupInZipfile = TextSecurePreferences.isRawBackupInZipfile(context); + // Extract the zipfile + if (rawBackupInZipfile) { + FileUtilsJW.extractEncryptedZipfile(context, getEncryptedZipfileName(), StorageUtil.getRawBackupDirectory().getAbsolutePath()); + } + verifyExternalStorageForImport(context); + importDirectory(context, ""); + importSharedSettings(context); + importSecrets(context); + if (rawBackupInZipfile) { + deleteRawBackupFiles(context); + } + } + + private static void importSharedSettings(Context context) { + String tempFileName = "tempsettings"; + String settingsFile = "org.thoughtcrime.securesms_preferences"; + + File fromPrefFile = new File(getExportDirectoryPath(context) + File.separator + "shared_prefs" + File.separator + settingsFile + ".xml"); + // Copy fromFile to shared_prefs + File toTempFile = new File(context.getFilesDir() + File.separator + ".." + File.separator + "shared_prefs" + File.separator + tempFileName + ".xml"); + migrateFile(fromPrefFile, toTempFile); + // Get default preferences + SharedPreferences defaultPreferences = context.getSharedPreferences(settingsFile, Context.MODE_PRIVATE); + // Load settings from backup file + SharedPreferences newPreferences = context.getSharedPreferences(tempFileName, Context.MODE_PRIVATE); + // Write settings to current file + copySharedPreferences(newPreferences, defaultPreferences); + // delete temp file + toTempFile.delete(); + } + + public static void copySharedPreferences(SharedPreferences fromPreferences, SharedPreferences toPreferences) { + SharedPreferences.Editor editor = toPreferences.edit(); + editor.clear(); + copySharedPreferences(fromPreferences, editor); + editor.commit(); + } + + public static void copySharedPreferences(SharedPreferences fromPreferences, SharedPreferences.Editor toEditor) { + for (Map.Entry entry : fromPreferences.getAll().entrySet()) { + Object value = entry.getValue(); + String key = entry.getKey(); + //Log.w(TAG, "copySharedPreferences: Key = " + key + ", Value = " + value.toString()); + if (value instanceof String) { + toEditor.putString(key, ((String) value)); + } else if (value instanceof Set) { + toEditor.putStringSet(key, (Set) value); // EditorImpl.putStringSet already creates a copy of the set + } else if (value instanceof Integer) { + toEditor.putInt(key, (Integer) value); + } else if (value instanceof Long) { + toEditor.putLong(key, (Long) value); + } else if (value instanceof Float) { + toEditor.putFloat(key, (Float) value); + } else if (value instanceof Boolean) { + toEditor.putBoolean(key, (Boolean) value); + } + } + } + + // Replace the secrets with the new versions. These values might change when the backup is + // restored on another device as where it is made. + private static void importSecrets(Context context) { + DatabaseSecret dbs = getDatabaseSecretFromBackup(context); + AttachmentSecret ats = getAttachmentSecretFromBackup(context); + byte[] lgs = getLogSecretFromBackup(context); + String bks = getBackupKeyFromBackup(context); + + if (dbs != null) { + overwriteDatabaseSecret(context, dbs); + } + if (ats != null) { + overwriteAttachmentSecret(context, ats); + } + if (lgs != null) { + overwriteLogSecret(context, lgs); + } + if (bks != null) { + BackupPassphrase.set(context, bks); + } + } + + private static String getExportDatabaseSecretFullName(Context context) { + return getExportSecretsDirectory(context) + databaseSecretFile; + } + + private static String getExportAttachmentSecretFullName(Context context) { + return getExportSecretsDirectory(context) + attachmentSecretFile; + } + + private static String getExportLogSecretFullName(Context context) { + return getExportSecretsDirectory(context) + logSecretFile; + } + + private static String getExportBackupKeyFullName(Context context) { + return getExportSecretsDirectory(context) + backupKeyFile; + } + + private static String getExportBaseDirectory(Context context) { + String basedir = Environment.getExternalStorageDirectory().getAbsolutePath(); + try { + basedir = StorageUtil.getRawBackupDirectory().getAbsolutePath(); + } catch (NoExternalStorageException e) { + Log.w(TAG, "getExportBaseDirectory failed: " + e.toString()); + } + return basedir; + } + + private static String getExportSecretsDirectory(Context context) { + return getExportBaseDirectory(context) + File.separator + secretsExportDirectory + File.separator; + } + + private static String getExportDirectoryPath(Context context) { + return getExportBaseDirectory(context) + File.separator + exportDirectory; + } + + private static void verifyExternalStorageForExport(Context context) throws NoExternalStorageException { + if (!Environment.getExternalStorageDirectory().canWrite()) + throw new NoExternalStorageException(); + + String exportDirectoryPath = getExportDirectoryPath(context); + File exportDirectory = new File(exportDirectoryPath); + + if (!exportDirectory.exists()) + exportDirectory.mkdir(); + } + + private static void verifyExternalStorageForImport(Context context) throws NoExternalStorageException { + if (!Environment.getExternalStorageDirectory().canRead() || + !(new File(getExportDirectoryPath(context)).exists())) + throw new NoExternalStorageException(); + } + + private static void migrateFile(File from, File to) { + try { + if (from.exists()) { + FileChannel source = new FileInputStream(from).getChannel(); + FileChannel destination = new FileOutputStream(to).getChannel(); + + destination.transferFrom(source, 0, source.size()); + source.close(); + destination.close(); + } + } catch (IOException ioe) { + Log.w(TAG, ioe); + } + } + + private static void exportDirectory(Context context, String directoryName) throws IOException { + if (!directoryName.equals("/lib") && !directoryName.equals("/code_cache") && !directoryName.equals("/cache")) { + File directory = new File(context.getFilesDir().getParent() + File.separatorChar + directoryName); + File exportDirectory = new File(getExportDirectoryPath(context) + File.separatorChar + directoryName); + + if (directory.exists()) { + exportDirectory.mkdirs(); + + File[] contents = directory.listFiles(); + + if (contents == null) + throw new IOException("directory.listFiles() is null for " + context.getFilesDir().getParent() + File.separatorChar + directoryName + "!"); + + for (int i=0;i= Build.VERSION_CODES.M) { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(databaseSecret.asBytes()); + TextSecurePreferences.setDatabaseEncryptedSecret(context, encryptedSecret.serialize()); + } else { + TextSecurePreferences.setDatabaseUnencryptedSecret(context, databaseSecret.asString()); + } + } + + private static void overwriteAttachmentSecret(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(attachmentSecret.serialize().getBytes()); + TextSecurePreferences.setAttachmentEncryptedSecret(context, encryptedSecret.serialize()); + } else { + TextSecurePreferences.setAttachmentUnencryptedSecret(context, attachmentSecret.serialize()); + } + } + + private static void overwriteLogSecret(@NonNull Context context, @NonNull byte[] lgs) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(lgs); + TextSecurePreferences.setLogEncryptedSecret(context, encryptedSecret.serialize()); + } else { + TextSecurePreferences.setLogUnencryptedSecret(context, Base64.encodeWithPadding(lgs)); + } + } + + // LogSecretProvider class functions. Copied here because they are all private + static byte[] getOrCreateLogSecret(@NonNull Context context) { + String unencryptedSecret = TextSecurePreferences.getLogUnencryptedSecret(context); + String encryptedSecret = TextSecurePreferences.getLogEncryptedSecret(context); + + if (unencryptedSecret != null) return parseUnencryptedSecret(unencryptedSecret); + else if (encryptedSecret != null) return parseEncryptedSecret(encryptedSecret); + else return createAndStoreSecret(context); + } + + private static byte[] parseUnencryptedSecret(String secret) { + try { + return Base64.decode(secret); + } catch (IOException e) { + throw new AssertionError("Failed to decode the unecrypted secret."); + } + } + + private static byte[] parseEncryptedSecret(String secret) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.SealedData.fromString(secret); + return KeyStoreHelper.unseal(encryptedSecret); + } else { + throw new AssertionError("OS downgrade not supported. KeyStore sealed data exists on platform < M!"); + } + } + + private static byte[] createAndStoreSecret(@NonNull Context context) { + SecureRandom random = new SecureRandom(); + byte[] secret = new byte[32]; + random.nextBytes(secret); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + KeyStoreHelper.SealedData encryptedSecret = KeyStoreHelper.seal(secret); + TextSecurePreferences.setLogEncryptedSecret(context, encryptedSecret.serialize()); + } else { + TextSecurePreferences.setLogUnencryptedSecret(context, Base64.encodeWithPadding(secret)); + } + return secret; + } + + private static String getEncryptedZipfileName() { + try { + String backupPath = StorageUtil.getRawBackupDirectory().getAbsolutePath(); + return backupPath + File.separator + "SignalExport.zip"; + } catch (NoExternalStorageException e) { + Log.w(TAG, "getEncryptedZipfileName failed: " + e.toString()); + return Environment.getExternalStorageDirectory().getAbsolutePath(); + } + } + + // Delete the exported contents of the data dir and the unencrypted keys. + private static void deleteRawBackupFiles(Context context) { + FileUtilsJW.secureDeleteRecursive(new File(getExportSecretsDirectory(context))); + FileUtilsJW.deleteRecursive(new File(getExportDirectoryPath(context))); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt index 12448a85887..b6ce607f0d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt @@ -97,6 +97,8 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD GROUP BY ${AttachmentTable.DATA_FILE} """ +// JW: don't remove link preview media from the list, we want to be able to selectively delete it. +/* private val GALLERY_MEDIA_QUERY = String.format( BASE_MEDIA_QUERY, """ @@ -116,7 +118,24 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD ${MessageTable.LINK_PREVIEWS} IS NULL """ ) +*/ + private val GALLERY_MEDIA_QUERY = String.format( + BASE_MEDIA_QUERY, + """ + ${AttachmentTable.DATA_FILE} IS NOT NULL AND + ${AttachmentTable.CONTENT_TYPE} NOT LIKE 'image/svg%' AND + (${AttachmentTable.CONTENT_TYPE} LIKE 'image/%' OR ${AttachmentTable.CONTENT_TYPE} LIKE 'video/%') + """ + ) + private val GALLERY_MEDIA_QUERY_INCLUDING_TEMP_VIDEOS = String.format( + BASE_MEDIA_QUERY, + """ + (${AttachmentTable.DATA_FILE} IS NOT NULL OR (${AttachmentTable.CONTENT_TYPE} LIKE 'video/%' AND ${AttachmentTable.REMOTE_INCREMENTAL_DIGEST} IS NOT NULL)) AND + ${AttachmentTable.CONTENT_TYPE} NOT LIKE 'image/svg%' AND + (${AttachmentTable.CONTENT_TYPE} LIKE 'image/%' OR ${AttachmentTable.CONTENT_TYPE} LIKE 'video/%') + """ + ) private val AUDIO_MEDIA_QUERY = String.format( BASE_MEDIA_QUERY, """ @@ -125,6 +144,8 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD """ ) +// JW +/* private val ALL_MEDIA_QUERY = String.format( BASE_MEDIA_QUERY, """ @@ -133,6 +154,8 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD ${MessageTable.LINK_PREVIEWS} IS NULL """ ) +*/ + private val ALL_MEDIA_QUERY = String.format(BASE_MEDIA_QUERY, "${AttachmentTable.DATA_FILE} IS NOT NULL AND ${AttachmentTable.CONTENT_TYPE} NOT LIKE 'text/x-signal-plain'") private val DOCUMENT_MEDIA_QUERY = String.format( BASE_MEDIA_QUERY, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index cf32a949fc5..80975e20002 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -5484,4 +5484,30 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } } + + //--------------------------------------------------------------------------- + // JW: Deletes only the attachment for the message, not the message itself. + fun deleteAttachmentsOnly(messageId: Long): Boolean { + val threadId = getThreadIdForMessage(messageId) + val attachmentTable = SignalDatabase.attachments + attachmentTable.deleteAttachmentsForMessage(messageId) + notifyConversationListeners(threadId) + OptimizeMessageSearchIndexJob.enqueue() + return true + } + + // JW: added functions required for PlaintextBackup + fun getMessageCount(): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .run() + .readToSingleInt() + } + + fun getMessages(skip: Int, limit: Int): Cursor { + val db = databaseHelper.signalReadableDatabase + return db.query(TABLE_NAME, MMS_PROJECTION, null, null, null, null, ID, skip.toString().plus(",").plus(limit.toString())) + } + //--------------------------------------------------------------------------- } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/PlaintextBackupExporter.java b/app/src/main/java/org/thoughtcrime/securesms/database/PlaintextBackupExporter.java new file mode 100644 index 00000000000..74195e4d462 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PlaintextBackupExporter.java @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms.database; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.FileUtilsJW; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; + +import java.io.File; +import java.io.IOException; + +public class PlaintextBackupExporter { + private static final String TAG = Log.tag(PlaintextBackupExporter.class); + + private static final String FILENAME = "SignalPlaintextBackup.xml"; + private static final String ZIPFILENAME = "SignalPlaintextBackup.zip"; + + public static void exportPlaintextToSd(Context context) + throws NoExternalStorageException, IOException + { + exportPlaintext(context); + } + + public static File getPlaintextExportFile() throws NoExternalStorageException { + return new File(StorageUtil.getBackupPlaintextDirectory(), FILENAME); + } + + private static File getPlaintextZipFile() throws NoExternalStorageException { + return new File(StorageUtil.getBackupPlaintextDirectory(), ZIPFILENAME); + } + + public @NonNull static String getSmsAddress(MessageRecord record) { + Recipient recipient = record.getFromRecipient(); + + if (recipient.getE164().isPresent()) { + return recipient.getE164().get(); + } else if (recipient.getEmail().isPresent()) { + return recipient.getEmail().get(); + } else { + return "null"; + } + } + + private static void exportPlaintext(Context context) + throws NoExternalStorageException, IOException + { + MessageTable messagetable = SignalDatabase.messages(); + int count = messagetable.getMessageCount(); + XmlBackup.Writer writer = new XmlBackup.Writer(getPlaintextExportFile().getAbsolutePath(), count); + + MessageRecord record; + + MessageTable.MmsReader messagereader = null; + int skip = 0; + int ROW_LIMIT = 500; + + do { + if (messagereader != null) + messagereader.close(); + + messagereader = messagetable.mmsReaderFor(messagetable.getMessages(skip, ROW_LIMIT)); + + try { + while ((record = messagereader.getNext()) != null) { + XmlBackup.XmlBackupItem item = + new XmlBackup.XmlBackupItem(0, + getSmsAddress(record), + record.getFromRecipient().getDisplayName(context), + record.getDateReceived(), + translateToSystemBaseType(record.getType()), + null, + record.getDisplayBody(context).toString(), + null, + 1, + record.getDeliveryStatus(), + getTransportType(record), + record.getToRecipient().getId().toLong()); + + writer.writeItem(item); + } + } + catch (Exception e) { + Log.w(TAG, "messagereader.getNext() failed: " + e.getMessage()); + } + + skip += ROW_LIMIT; + } while (messagereader.getCount() > 0); + + writer.close(); + + if (TextSecurePreferences.isPlainBackupInZipfile(context)) { + File test = new File(getPlaintextZipFile().getAbsolutePath()); + if (test.exists()) { + test.delete(); + } + FileUtilsJW.createEncryptedPlaintextZipfile(context, getPlaintextZipFile().getAbsolutePath(), getPlaintextExportFile().getAbsolutePath()); + getPlaintextExportFile().delete(); // Insecure, leaves possibly recoverable plaintext on device + // FileUtilsJW.secureDelete(getPlaintextExportFile()); // much too slow + } + } + + private static String getTransportType(MessageRecord messageRecord) { + String transportText = "-"; + if (messageRecord.isOutgoing() && messageRecord.isFailed()) { + transportText = "-"; + } else if (messageRecord.isPending()) { + transportText = "Pending"; + } else if (messageRecord.isPush()) { + transportText = "Data"; + } else if (messageRecord.isMms()) { + transportText = "MMS"; + } else { + transportText = "SMS"; + } + return transportText; + } + + public static int translateToSystemBaseType(long type) { + if (isInboxType(type)) return 1; + else if (isOutgoingMessageType(type)) return 2; + else if (isFailedMessageType(type)) return 5; + + return 1; + } + + public static boolean isInboxType(long type) { + return (type & MessageTypes.BASE_TYPE_MASK) == MessageTypes.BASE_INBOX_TYPE; + } + + public static boolean isOutgoingMessageType(long type) { + for (long outgoingType : MessageTypes.OUTGOING_MESSAGE_TYPES) { + if ((type & MessageTypes.BASE_TYPE_MASK) == outgoingType) + return true; + } + + return false; + } + + public static boolean isFailedMessageType(long type) { + return (type & MessageTypes.BASE_TYPE_MASK) == MessageTypes.BASE_SENT_FAILED_TYPE; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/PlaintextBackupImporter.java b/app/src/main/java/org/thoughtcrime/securesms/database/PlaintextBackupImporter.java new file mode 100644 index 00000000000..c4d7ae14472 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/PlaintextBackupImporter.java @@ -0,0 +1,153 @@ +package org.thoughtcrime.securesms.database; + +import android.content.Context; +import android.os.Environment; + +import net.zetetic.database.sqlcipher.SQLiteStatement; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.FileUtilsJW; +import org.thoughtcrime.securesms.util.StorageUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.File; +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; + +public class PlaintextBackupImporter { + + private static final String TAG = Log.tag(PlaintextBackupImporter.class); + + public static SQLiteStatement createMessageInsertStatement(SQLiteDatabase database) { + return database.compileStatement("INSERT INTO " + MessageTable.TABLE_NAME + " (" + + MessageTable.FROM_RECIPIENT_ID + ", " + + MessageTable.DATE_SENT + ", " + + MessageTable.DATE_RECEIVED + ", " + + MessageTable.READ + ", " + + MessageTable.MMS_STATUS + ", " + + MessageTable.TYPE + ", " + + MessageTable.BODY + ", " + + MessageTable.THREAD_ID + ", " + + MessageTable.TO_RECIPIENT_ID + ") " + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); + } + + public static void importPlaintextFromSd(Context context) throws NoExternalStorageException, IOException + { + Log.i(TAG, "importPlaintext()"); + // Unzip zipfile first if required + if (TextSecurePreferences.isPlainBackupInZipfile(context)) { + File zipFile = getPlaintextExportZipFile(); + FileUtilsJW.extractEncryptedZipfile(context, zipFile.getAbsolutePath(), StorageUtil.getBackupPlaintextDirectory().getAbsolutePath()); + } + MessageTable table = SignalDatabase.messages(); + SQLiteDatabase transaction = table.beginTransaction(); + + try { + ThreadTable threadTable = SignalDatabase.threads(); + XmlBackup backup = new XmlBackup(getPlaintextExportFile().getAbsolutePath()); + Set modifiedThreads = new HashSet<>(); + XmlBackup.XmlBackupItem item; + + // TODO: we might have to split this up in chunks of about 5000 messages to prevent these errors: + // java.util.concurrent.TimeoutException: net.sqlcipher.database.SQLiteCompiledSql.finalize() timed out after 10 seconds + while ((item = backup.getNext()) != null) { + Recipient recipient = Recipient.external(context, item.getAddress()); + long threadId = threadTable.getOrCreateThreadIdFor(recipient); + SQLiteStatement statement = createMessageInsertStatement(transaction); + + if (item.getAddress() == null || item.getAddress().equals("null")) + continue; + + if (!isAppropriateTypeForImport(item.getType())) + continue; + + addStringToStatement(statement, 1, recipient.getId().serialize()); + addLongToStatement(statement, 2, item.getDate()); + addLongToStatement(statement, 3, item.getDate()); + addLongToStatement(statement, 4, item.getRead()); + addLongToStatement(statement, 5, item.getStatus()); + addTranslatedTypeToStatement(statement, 6, item.getType()); + addStringToStatement(statement, 7, item.getBody()); + addLongToStatement(statement, 8, threadId); + addLongToStatement(statement, 9, item.getRecipient()); + modifiedThreads.add(threadId); + //statement.execute(); + long rowId = statement.executeInsert(); + } + + for (long threadId : modifiedThreads) { + threadTable.update(threadId, true, true); + } + + table.setTransactionSuccessful(); + } catch (XmlPullParserException e) { + Log.w(TAG, e); + throw new IOException("XML Parsing error!"); + } finally { + table.endTransaction(transaction); + } + // Delete the plaintext file if zipfile is present + if (TextSecurePreferences.isPlainBackupInZipfile(context)) { + getPlaintextExportFile().delete(); // Insecure, leaves possibly recoverable plaintext on device + // FileUtilsJW.secureDelete(getPlaintextExportFile()); // much too slow + } + } + + private static File getPlaintextExportFile() throws NoExternalStorageException { + File backup = new File(StorageUtil.getBackupPlaintextDirectory(), "SignalPlaintextBackup.xml"); + File previousBackup = new File(StorageUtil.getLegacyBackupDirectory(), "SignalPlaintextBackup.xml"); + File oldBackup = new File(Environment.getExternalStorageDirectory(), "TextSecurePlaintextBackup.xml"); + + if (backup.exists()) return backup; + else if (previousBackup.exists()) return previousBackup; + else if (oldBackup.exists()) return oldBackup; + else return backup; + } + + private static File getPlaintextExportZipFile() throws NoExternalStorageException { + return new File(StorageUtil.getBackupPlaintextDirectory(), "SignalPlaintextBackup.zip"); + } + + @SuppressWarnings("SameParameterValue") + private static void addTranslatedTypeToStatement(SQLiteStatement statement, int index, int type) { + statement.bindLong(index, translateFromSystemBaseType(type)); + } + + private static void addStringToStatement(SQLiteStatement statement, int index, String value) { + if (value == null || value.equals("null")) statement.bindNull(index); + else statement.bindString(index, value); + } + + private static void addNullToStatement(SQLiteStatement statement, int index) { + statement.bindNull(index); + } + + private static void addLongToStatement(SQLiteStatement statement, int index, long value) { + statement.bindLong(index, value); + } + + private static boolean isAppropriateTypeForImport(long theirType) { + long ourType = translateFromSystemBaseType(theirType); + + return ourType == MessageTypes.BASE_INBOX_TYPE || + ourType == MessageTypes.BASE_SENT_TYPE || + ourType == MessageTypes.BASE_SENT_FAILED_TYPE; + } + + public static long translateFromSystemBaseType(long theirType) { + switch ((int)theirType) { + case 1: return MessageTypes.BASE_INBOX_TYPE; + case 2: return MessageTypes.BASE_SENT_TYPE; + case 3: return MessageTypes.BASE_DRAFT_TYPE; + case 4: return MessageTypes.BASE_OUTBOX_TYPE; + case 5: return MessageTypes.BASE_SENT_FAILED_TYPE; + case 6: return MessageTypes.BASE_OUTBOX_TYPE; + } + + return MessageTypes.BASE_INBOX_TYPE; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/WhatsappBackup.java b/app/src/main/java/org/thoughtcrime/securesms/database/WhatsappBackup.java new file mode 100644 index 00000000000..ccce80a9770 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/WhatsappBackup.java @@ -0,0 +1,267 @@ +package org.thoughtcrime.securesms.database; + +import android.annotation.SuppressLint; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.Environment; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.LinkedList; +import java.util.List; + +public class WhatsappBackup { + + public static final String TAG = WhatsappBackup.class.getSimpleName(); + + private static final String PROTOCOL = "protocol"; + private static final String ADDRESS = "address"; + private static final String CONTACT_NAME = "contact_name"; + private static final String DATE = "date"; + private static final String READABLE_DATE = "readable_date"; + private static final String TYPE = "type"; + private static final String SUBJECT = "subject"; + private static final String BODY = "body"; + private static final String SERVICE_CENTER = "service_center"; + private static final String READ = "read"; + private static final String STATUS = "status"; + private static final String TOA = "toa"; + private static final String SC_TOA = "sc_toa"; + private static final String LOCKED = "locked"; + private static final String TRANSPORT = "transport"; + private static final String GROUP_NAME = "group_name"; + + private static final SimpleDateFormat dateFormatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z"); + + private final android.database.sqlite.SQLiteDatabase whatsappDb; + private long dbOffset = 0l; + + public WhatsappBackup(android.database.sqlite.SQLiteDatabase whatsappDb) { + this.whatsappDb = whatsappDb; + } + + @SuppressLint("Range") + public static List getMediaAttachments(SQLiteDatabase whatsappDb, WhatsappBackupItem item) { + List attachments = new LinkedList<>(); + try { + Cursor c = whatsappDb.rawQuery("SELECT * FROM message_media WHERE message_row_id=" + item.getWaMessageId() +" LIMIT 1", null); + if (c != null) { + if (c.moveToFirst()) { + do { + File storagePath = Environment.getExternalStorageDirectory(); + String filePath = storagePath.getAbsolutePath() + File.separator + "Android/media/com.whatsapp/WhatsApp" + File.separator + c.getString(c.getColumnIndex("file_path")); + int size = c.getInt(c.getColumnIndex("file_size")); + String type = c.getString(c.getColumnIndex("mime_type")); + File file = new File(filePath); + if (!file.exists()) return attachments; + Uri uri = Uri.fromFile(file); + String name = filePath; + if (type.equals("image/jpeg")) { + Attachment attachment = new UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentTable.TRANSFER_PROGRESS_DONE, + size, name, false, false, false, false, item.getMediaCaption(), null, null, null, null); + attachments.add(attachment); + } else if (type.equals("video/mp4")) { + Attachment attachment = new UriAttachment(uri, MediaUtil.VIDEO_MP4, AttachmentTable.TRANSFER_PROGRESS_DONE, + size, name, false, false, false, false, item.getMediaCaption(), null, null, null, null); + attachments.add(attachment); + } else if (type.equals("audio/ogg; codecs=opus")) { + Attachment attachment = new UriAttachment(uri, MediaUtil.AUDIO_UNSPECIFIED, AttachmentTable.TRANSFER_PROGRESS_DONE, + size, name, true, false, false, false, null, null, null, null, null); + attachments.add(attachment); + } else { + return attachments; // Ignore everything that is not an image or a video for the moment + } + return attachments; + } + while (c.moveToNext()); + } + c.close(); + } + }catch(Exception e2){ + Log.w(TAG, e2.getMessage()); + } + return attachments; + } + + @SuppressLint("Range") + public WhatsappBackup.WhatsappBackupItem getNext() { + WhatsappBackup.WhatsappBackupItem item = null; + try { + Cursor c = whatsappDb.rawQuery("SELECT * FROM messages LIMIT "+ dbOffset + ", 1", null); + if (c != null) { + if (c.moveToFirst()) { + do { + item = new WhatsappBackup.WhatsappBackupItem(); + item.subject = null; + item.body = c.getString(c.getColumnIndex("data")); + item.protocol = 0; + String rawAddress = c.getString(c.getColumnIndex("key_remote_jid")); + if (rawAddress != null && rawAddress.trim().length() > 0) { + item.address = "+" + rawAddress.split("@")[0]; // Only keep the phone number + } + if (item.address.contains("-")) { // Check if it's a group message + item.groupName = getGroupName(c.getString(c.getColumnIndex("key_remote_jid"))); + rawAddress = c.getString(c.getColumnIndex("remote_resource")); + if (rawAddress != null && rawAddress.trim().length() > 0) { + item.address = "+" + c.getString(c.getColumnIndex("remote_resource")).split("@")[0]; + } else { + item.address = null; + } + } + item.contactName = null; + item.date = c.getLong(c.getColumnIndex("timestamp")); + item.readableDate = dateFormatter.format(item.date); + int fromMe = c.getInt(c.getColumnIndex("key_from_me")); + item.type = (int)(fromMe == 1 ? 2 : 1); + item.serviceCenter = null; + item.read = 1; + item.status = MessageTable.Status.STATUS_COMPLETE; + item.transport = "Data"; + item.mediaWaType = c.getInt(c.getColumnIndex("media_wa_type")); + item.waMessageId = c.getLong(c.getColumnIndex("_id")); + item.mediaCaption = c.getString(c.getColumnIndex("media_caption")); + } + while (c.moveToNext()); + } + c.close(); + } + }catch(Exception e2){ + Log.w(TAG, e2.getMessage()); + } + + dbOffset++; + //if (dbOffset == 3000) return null; // Limit number of imported messages for quick testing + return item; + } + + @SuppressLint("Range") + private String getGroupName(String keyRemoteJid) { + try { + + Cursor c = whatsappDb.rawQuery("SELECT subject FROM chat JOIN jid ON chat.jid_row_id=jid._id WHERE jid.raw_string='" + keyRemoteJid + "' LIMIT 1", null); + if (c != null) { + if (c.moveToFirst()) { + do { + String groupName = c.getString(c.getColumnIndex("subject")); + return groupName; + } + while (c.moveToNext()); + } + c.close(); + } + }catch(Exception e2) { + Log.w(TAG, e2.getMessage()); + } + return null; + } + + public static class WhatsappBackupItem { + private int protocol; + private String address; + private String contactName; + private long date; + private String readableDate; + private int type; + private String subject; + private String body; + private String serviceCenter; + private int read; + private int status; + private String transport; + private String groupName; + private int mediaWaType; // + private long waMessageId; + private String mediaCaption; + + public WhatsappBackupItem() {} + + public WhatsappBackupItem(int protocol, String address, String contactName, long date, int type, + String subject, String body, String serviceCenter, int read, int status, + String transport) + { + this.protocol = protocol; + this.address = address; + this.contactName = contactName; + this.date = date; + this.readableDate = dateFormatter.format(date); + this.type = type; + this.subject = subject; + this.body = body; + this.serviceCenter = serviceCenter; + this.read = read; + this.status = status; + this.transport = transport; + this.groupName = null; + this.mediaWaType = 0; + this.waMessageId = 0; + } + + public int getProtocol() { + return protocol; + } + + public String getAddress() { + return address; + } + + public String getContactName() { + return contactName; + } + + public long getDate() { + return date; + } + + public String getReadableDate() { + return readableDate; + } + + public int getType() { + return type; + } + + public String getSubject() { + return subject; + } + + public String getBody() { + return body; + } + + public String getServiceCenter() { + return serviceCenter; + } + + public int getRead() { + return read; + } + + public int getStatus() { + return status; + } + + public String getGroupName() { + return groupName; + } + + public int getMediaWaType() { + return mediaWaType; + } + + public long getWaMessageId() { + return waMessageId; + } + + public String getTransport() { return transport; } + + public String getMediaCaption() { + return mediaCaption; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/WhatsappBackupImporter.java b/app/src/main/java/org/thoughtcrime/securesms/database/WhatsappBackupImporter.java new file mode 100644 index 00000000000..72fe8bbdd72 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/WhatsappBackupImporter.java @@ -0,0 +1,273 @@ +package org.thoughtcrime.securesms.database; + +import android.app.ProgressDialog; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; + +//import com.google.android.mms.pdu_alt.PduHeaders; + +import net.zetetic.database.sqlcipher.SQLiteStatement; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.database.model.GroupRecord; +import org.thoughtcrime.securesms.database.whatsapp.WaDbOpenHelper; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.io.IOException; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.thoughtcrime.securesms.database.MessageTable.DATE_RECEIVED; +import static org.thoughtcrime.securesms.database.MessageTable.DATE_SENT; +import static org.thoughtcrime.securesms.database.MessageTable.TYPE; +import static org.thoughtcrime.securesms.database.MessageTable.MMS_MESSAGE_TYPE; +import static org.thoughtcrime.securesms.database.MessageTable.MMS_STATUS; +import static org.thoughtcrime.securesms.database.MessageTable.TABLE_NAME; +import static org.thoughtcrime.securesms.database.MessageTable.VIEW_ONCE; +import static org.thoughtcrime.securesms.database.MessageTable.DATE_SERVER; +import static org.thoughtcrime.securesms.database.MessageTable.EXPIRES_IN; +import static org.thoughtcrime.securesms.database.MessageTable.READ; +import static org.thoughtcrime.securesms.database.MessageTable.FROM_RECIPIENT_ID; +import static org.thoughtcrime.securesms.database.MessageTable.SMS_SUBSCRIPTION_ID; +import static org.thoughtcrime.securesms.database.MessageTable.THREAD_ID; +import static org.thoughtcrime.securesms.database.MessageTable.BASE_INBOX_TYPE; +import static org.thoughtcrime.securesms.database.MessageTable.BASE_SENT_TYPE; +import static org.thoughtcrime.securesms.database.MessageTable.UNIDENTIFIED; + +public class WhatsappBackupImporter { + // JW: from com.google.android.mms.pdu_alt.PduHeaders + public static final int MESSAGE_TYPE_RETRIEVE_CONF = 0x84; + + private static final String TAG = org.thoughtcrime.securesms.database.PlaintextBackupImporter.class.getSimpleName(); + + private static android.database.sqlite.SQLiteDatabase openWhatsappDb(Context context) throws NoExternalStorageException { + try { + android.database.sqlite.SQLiteOpenHelper db = new WaDbOpenHelper(context); + android.database.sqlite.SQLiteDatabase newdb = db.getReadableDatabase(); + return newdb; + } catch(Exception e2){ + throw new NoExternalStorageException(); + } + } + + public static void importWhatsappFromSd(Context context, ProgressDialog progressDialog, boolean importGroups, boolean avoidDuplicates, boolean importMedia) + throws NoExternalStorageException, IOException + { + Log.w(TAG, "importWhatsapp(): importGroup: " + importGroups + ", avoidDuplicates: " + avoidDuplicates); + android.database.sqlite.SQLiteDatabase whatsappDb = openWhatsappDb(context); + MessageTable messageDb = SignalDatabase.messages(); + //MmsTable mmsDb = SignalDatabase.mms(); + AttachmentTable attachmentDb = SignalDatabase.attachments(); + SQLiteDatabase smsDbTransaction = messageDb.beginTransaction(); + int numMessages = getNumMessages(whatsappDb, importMedia); + progressDialog.setMax(numMessages); + try { + ThreadTable threads = SignalDatabase.threads(); + GroupTable groups = SignalDatabase.groups(); + WhatsappBackup backup = new WhatsappBackup(whatsappDb); + Set modifiedThreads = new HashSet<>(); + WhatsappBackup.WhatsappBackupItem item; + + int msgCount = 0; + while ((item = backup.getNext()) != null) { + msgCount++; + progressDialog.setProgress(msgCount); + Recipient recipient = getRecipient(context, item); + if (isGroupMessage(item) && !importGroups) continue; + long threadId = getThreadId(item, groups, threads, recipient); + + if (threadId == -1) continue; + + if (isMms(item)) { + if (!importMedia) continue; + if (avoidDuplicates && wasMsgAlreadyImported(smsDbTransaction, MessageTable.TABLE_NAME, MessageTable.DATE_SENT, threadId, recipient, item)) continue; + List attachments = WhatsappBackup.getMediaAttachments(whatsappDb, item); + if (attachments != null && attachments.size() > 0) insertMms(messageDb, attachmentDb, item, recipient, threadId, attachments); + } else { + if (item.getBody() == null) continue; //Ignore empty msgs for e.g. change of security numbers + if (avoidDuplicates && wasMsgAlreadyImported(smsDbTransaction, MessageTable.TABLE_NAME, MessageTable.DATE_SENT, threadId, recipient, item)) continue; + insertSms(messageDb, smsDbTransaction, item, recipient, threadId); + } + modifiedThreads.add(threadId); + } + + for (long threadId : modifiedThreads) { + threads.update(threadId, true, true); + } + + whatsappDb.setTransactionSuccessful(); + Log.w(TAG, "Exited loop"); + } catch (Exception e) { + Log.w(TAG, e); + throw new IOException("Whatsapp Import error!"); + } finally { + whatsappDb.close(); + messageDb.endTransaction(smsDbTransaction); + } + + } + + private static boolean wasMsgAlreadyImported(SQLiteDatabase db, String tableName, String dateField, long threadId, Recipient recipient, WhatsappBackup.WhatsappBackupItem item) { + String[] cols = new String[] {"COUNT(*)"}; + String query = THREAD_ID + " = ? AND " + dateField + " = ? AND " + FROM_RECIPIENT_ID + " = ?"; + String[] args = new String[]{String.valueOf(threadId), String.valueOf(item.getDate()), String.valueOf(recipient.getId().serialize())}; + + try (Cursor cursor = db.query(tableName, cols, query, args, null, null, null)) { + if (cursor != null) { + if (cursor.moveToFirst() && cursor.getInt(0) > 0) { + cursor.close(); + return true; + } + cursor.close(); + } + } + return false; + } + + private static int getNumMessages(android.database.sqlite.SQLiteDatabase whatsappDb, boolean importMedia) { + String whereClause = ""; + if (!importMedia) whereClause = " WHERE data!=''"; + try { + Cursor c = whatsappDb.rawQuery("SELECT COUNT(*) FROM messages" + whereClause, null); + if (c != null) { + if (c.moveToFirst()) { + int count = c.getInt(0); + return count; + } + c.close(); + } + }catch(Exception e2){ + Log.w(TAG, e2.getMessage()); + } + return 0; + } + + private static Recipient getRecipient(Context context, WhatsappBackup.WhatsappBackupItem item) { + Recipient recipient; + if (item.getAddress() == null) { + recipient = Recipient.self(); + } else { + recipient = Recipient.external(context, item.getAddress()); + } + return recipient; + } + + private static long getThreadId(WhatsappBackup.WhatsappBackupItem item, GroupTable groups, ThreadTable threads, Recipient recipient) { + long threadId; + if (isGroupMessage(item)) { + RecipientId threadRecipientId = getGroupId(groups, item, recipient); + if (threadRecipientId == null) return -1; + try { + Recipient threadRecipient = Recipient.resolved(threadRecipientId); + threadId = threads.getOrCreateThreadIdFor(threadRecipient); + } catch (Exception e) { + Log.v(TAG, "Group not found: " + item.getGroupName()); + return -1; + } + } else { + threadId = threads.getOrCreateThreadIdFor(recipient); + } + return threadId; + } + + private static boolean isMms(WhatsappBackup.WhatsappBackupItem item) { + if (item.getMediaWaType() != 0) return true; + return false; + } + + private static void insertMms(MessageTable mmsDb, AttachmentTable attachmentDb, WhatsappBackup.WhatsappBackupItem item, Recipient recipient, long threadId, List attachments) throws MmsException { + List quoteAttachments = new LinkedList<>(); + ContentValues contentValues = new ContentValues(); + contentValues.put(DATE_SENT, item.getDate()); + contentValues.put(DATE_SERVER, item.getDate()); + contentValues.put(FROM_RECIPIENT_ID, recipient.getId().serialize()); + if (item.getType() == 1) { + contentValues.put(TYPE, BASE_INBOX_TYPE); + } else { + contentValues.put(TYPE, BASE_SENT_TYPE); + } + contentValues.put(MMS_MESSAGE_TYPE, MESSAGE_TYPE_RETRIEVE_CONF); + contentValues.put(THREAD_ID, threadId); + contentValues.put(MMS_STATUS, MessageTable.MmsStatus.DOWNLOAD_INITIALIZED); + contentValues.put(DATE_RECEIVED, item.getDate()); + contentValues.put(SMS_SUBSCRIPTION_ID, -1); + contentValues.put(EXPIRES_IN, 0); + contentValues.put(VIEW_ONCE, 0); + contentValues.put(READ, 1); + contentValues.put(UNIDENTIFIED, 0); + + SQLiteDatabase transaction = mmsDb.beginTransaction(); + long messageId = transaction.insert(TABLE_NAME, null, contentValues); + + Map insertedAttachments = attachmentDb.insertAttachmentsForMessage(messageId, attachments, quoteAttachments); + mmsDb.setTransactionSuccessful(); + mmsDb.endTransaction(); + + } + + private static void insertSms(MessageTable smsDb, SQLiteDatabase transaction, WhatsappBackup.WhatsappBackupItem item, Recipient recipient, long threadId) { + SQLiteStatement statement = PlaintextBackupImporter.createMessageInsertStatement(transaction); + + addStringToStatement(statement, 1, recipient.getId().serialize()); + addLongToStatement(statement, 2, item.getDate()); + addLongToStatement(statement, 3, item.getDate()); + addLongToStatement(statement, 4, item.getRead()); + addLongToStatement(statement, 5, item.getStatus()); + addTranslatedTypeToStatement(statement, 6, item.getType()); + addStringToStatement(statement, 7, item.getBody()); + addLongToStatement(statement, 8, threadId); + + statement.execute(); + statement.close(); + } + + private static RecipientId getGroupId(GroupTable groups, WhatsappBackup.WhatsappBackupItem item, Recipient recipient) { + if (item.getGroupName() == null) return null; + List groupRecords = groups.getGroupsContainingMember(recipient.getId(), false); + for (GroupRecord group : groupRecords) { + if (group.getTitle().equals(item.getGroupName())) { + return group.getRecipientId(); + } + } + return null; + } + + private static boolean isGroupMessage(WhatsappBackup.WhatsappBackupItem item) { + if (item.getGroupName() != null) return true; + return false; + } + + @SuppressWarnings("SameParameterValue") + private static void addTranslatedTypeToStatement(SQLiteStatement statement, int index, int type) { + statement.bindLong(index, PlaintextBackupImporter.translateFromSystemBaseType(type)); + } + + private static void addStringToStatement(SQLiteStatement statement, int index, String value) { + if (value == null || value.equals("null")) statement.bindNull(index); + else statement.bindString(index, value); + } + + private static void addNullToStatement(SQLiteStatement statement, int index) { + statement.bindNull(index); + } + + private static void addLongToStatement(SQLiteStatement statement, int index, long value) { + statement.bindLong(index, value); + } + + private static boolean isAppropriateTypeForImport(long theirType) { + long ourType = PlaintextBackupImporter.translateFromSystemBaseType(theirType); + + return ourType == BASE_INBOX_TYPE || + ourType == MessageTable.BASE_SENT_TYPE || + ourType == MessageTable.BASE_SENT_FAILED_TYPE; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/XmlBackup.java b/app/src/main/java/org/thoughtcrime/securesms/database/XmlBackup.java index a206825a013..9a5c317ae8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/XmlBackup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/XmlBackup.java @@ -32,6 +32,8 @@ public class XmlBackup { private static final String TOA = "toa"; private static final String SC_TOA = "sc_toa"; private static final String LOCKED = "locked"; + private static final String TRANSPORT = "transport"; + private static final String RECIPIENT = "torecipient"; private static final SimpleDateFormat dateFormatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z"); @@ -77,11 +79,11 @@ public XmlBackupItem getNext() throws IOException, XmlPullParserException { else if (attributeName.equals(SERVICE_CENTER)) item.serviceCenter = parser.getAttributeValue(i); else if (attributeName.equals(READ )) item.read = Integer.parseInt(parser.getAttributeValue(i)); else if (attributeName.equals(STATUS )) item.status = Integer.parseInt(parser.getAttributeValue(i)); + else if (attributeName.equals(TRANSPORT )) item.transport = parser.getAttributeValue(i); + else if (attributeName.equals(RECIPIENT )) item.torecipient = Long.parseLong(parser.getAttributeValue(i)); } - return item; } - return null; } @@ -97,11 +99,13 @@ public static class XmlBackupItem { private String serviceCenter; private int read; private int status; - + private String transport; + private long torecipient; public XmlBackupItem() {} public XmlBackupItem(int protocol, String address, String contactName, long date, int type, - String subject, String body, String serviceCenter, int read, int status) + String subject, String body, String serviceCenter, int read, int status, + String transport, long torecipient) { this.protocol = protocol; this.address = address; @@ -114,6 +118,8 @@ public XmlBackupItem(int protocol, String address, String contactName, long date this.serviceCenter = serviceCenter; this.read = read; this.status = status; + this.transport = transport; + this.torecipient = torecipient; } public int getProtocol() { @@ -159,6 +165,9 @@ public int getRead() { public int getStatus() { return status; } + + public String getTransport() { return transport; } + public long getRecipient() { return torecipient; } } public static class Writer { @@ -204,6 +213,8 @@ public void writeItem(XmlBackupItem item) throws IOException { appendAttribute(stringBuilder, READ, item.getRead()); appendAttribute(stringBuilder, STATUS, item.getStatus()); appendAttribute(stringBuilder, LOCKED, 0); + appendAttribute(stringBuilder, TRANSPORT, item.getTransport()); + appendAttribute(stringBuilder, RECIPIENT, item.getRecipient()); stringBuilder.append(CLOSE_EMPTYTAG); bufferedWriter.newLine(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/whatsapp/WaDbContext.java b/app/src/main/java/org/thoughtcrime/securesms/database/whatsapp/WaDbContext.java new file mode 100644 index 00000000000..edd624c231e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/whatsapp/WaDbContext.java @@ -0,0 +1,57 @@ +package org.thoughtcrime.securesms.database.whatsapp; + +import android.content.Context; +import android.content.ContextWrapper; +import android.database.DatabaseErrorHandler; +import android.database.sqlite.SQLiteDatabase; +import android.os.Environment; +import android.util.Log; + +import org.thoughtcrime.securesms.database.NoExternalStorageException; + +import java.io.File; + +/** + * Created by Man on 10/31/2016. + */ + +public class WaDbContext extends ContextWrapper { + + public WaDbContext(Context base) { + super(base); + } + + @Override + public File getDatabasePath(String name) { + File sdcard = Environment.getExternalStorageDirectory(); + String dbfile = sdcard.getAbsolutePath() + File.separator + name; + if (!dbfile.endsWith(".db")) { + dbfile += ".db" ; + } + + File result = new File(dbfile); + + if (Log.isLoggable("DEBUG_CONTEXT", Log.WARN)) { + Log.w("DEBUG_CONTEXT", "getDatabasePath(" + name + ") = " + result.getAbsolutePath()); + } + + return result; + } + + /* this version is called for android devices >= api-11. thank to @damccull for fixing this. */ + @Override + public SQLiteDatabase openOrCreateDatabase(String name, int mode, SQLiteDatabase.CursorFactory factory, DatabaseErrorHandler errorHandler) { + return openOrCreateDatabase(name,mode, factory); + } + + /* this version is called for android devices < api-11 */ + @Override + public SQLiteDatabase openOrCreateDatabase(String name, int mode, SQLiteDatabase.CursorFactory factory) { + SQLiteDatabase result = SQLiteDatabase.openOrCreateDatabase(getDatabasePath(name), null); + // SQLiteDatabase result = super.openOrCreateDatabase(name, mode, factory); + if (Log.isLoggable("DEBUG_CONTEXT", Log.WARN)) { + Log.w("DEBUG_CONTEXT", "openOrCreateDatabase(" + name + ",,) = " + result.getPath()); + } + return result; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/whatsapp/WaDbOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/whatsapp/WaDbOpenHelper.java new file mode 100644 index 00000000000..caf8569dec9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/whatsapp/WaDbOpenHelper.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.database.whatsapp; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + + +public class WaDbOpenHelper extends SQLiteOpenHelper { + + private static String DB_NAME = "msgstore.db"; + + public WaDbOpenHelper(Context context) + { + super(new WaDbContext(context), DB_NAME, null, 3); + + } + + @Override + public void onCreate(SQLiteDatabase db) { + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt index a6fc03550b5..fd4f46a869a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt @@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.mms.OutgoingMessage import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.TextSecurePreferences // JW import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct @@ -553,6 +554,19 @@ class GroupsV2StateProcessor private constructor( @VisibleForTesting internal class ProfileAndMessageHelper(private val aci: ACI, private val masterKey: GroupMasterKey, private val groupId: GroupId.V2) { + // JW: Check if the person is allowed to add you to a group + private fun mayThisPersonAddYouToAGroup(addedBy: Recipient): Boolean { + when (TextSecurePreferences.whoCanAddYouToGroups(AppDependencies.application)) { + "anyone" -> return true + "nonblocked" -> return !addedBy.isBlocked + "onlycontacts" -> return addedBy.isProfileSharing && !addedBy.isBlocked // check for blocked is not necessary but defensive programming if something changes here + "onlysystemcontacts" -> return addedBy.isSystemContact && !addedBy.isBlocked + "nobody" -> return false + else -> return true + } + return true + } + fun setProfileSharing(groupStateDiff: GroupStateDiff, newLocalState: DecryptedGroup) { val previousGroupState = groupStateDiff.previousGroupState @@ -577,11 +591,12 @@ class GroupsV2StateProcessor private constructor( if (addedBy != null) { Log.i(TAG, "Added as a full member of $groupId by ${addedBy.id}") - if (addedBy.isBlocked && (previousGroupState == null || !DecryptedGroupUtil.isRequesting(previousGroupState, aci))) { + // JW: changed logic with more options + if (!mayThisPersonAddYouToAGroup(addedBy) && (previousGroupState == null || !DecryptedGroupUtil.isRequesting(previousGroupState, aci))) { Log.i(TAG, "Added by a blocked user. Leaving group.") AppDependencies.jobManager.add(LeaveGroupV2Job(groupId)) return - } else if ((addedBy.isSystemContact || addedBy.isProfileSharing) && !addedBy.isHidden) { + } else if ((addedBy.isSystemContact || addedBy.isProfileSharing) && !addedBy.isHidden && !addedBy.isBlocked) { // JW: added isBlocked explicitly here Log.i(TAG, "Group 'adder' is trusted. contact: " + addedBy.isSystemContact + ", profileSharing: " + addedBy.isProfileSharing) Log.i(TAG, "Added to a group and auto-enabling profile sharing") SignalDatabase.recipients.setProfileSharing(Recipient.externalGroupExact(groupId).id, true) @@ -594,7 +609,7 @@ class GroupsV2StateProcessor private constructor( } else if (selfAsPending != null) { val addedBy = UuidUtil.fromByteStringOrNull(selfAsPending.addedByAci)?.let { Recipient.externalPush(ACI.from(it)) } - if (addedBy?.isBlocked == true) { + if (!mayThisPersonAddYouToAGroup(addedBy!!)) { // JW: replaced blocked by more general permission Log.i(TAG, "Added to group $groupId by a blocked user ${addedBy.id}. Leaving group.") AppDependencies.jobManager.add(LeaveGroupV2Job(groupId)) return @@ -735,7 +750,7 @@ class GroupsV2StateProcessor private constructor( } catch (e: MmsException) { Log.w(TAG, "Failed to insert outgoing update message!", e) } - } else { + } else if (!TextSecurePreferences.whoCanAddYouToGroups(AppDependencies.application).equals("nonblocked") || !Recipient.resolved(RecipientId.from(editor.get())).isBlocked) { // JW: don't store messages from blocked contacts try { val isGroupAdd = updateDescription .groupChangeUpdate!! diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt index 0f8f6a66e8e..073c94236de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt @@ -71,7 +71,7 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto /** * Whether or not the client is currently in a 'deprecated' state, disallowing network access. */ - var isClientDeprecated: Boolean by booleanValue(CLIENT_DEPRECATED, false) + var isClientDeprecated: Boolean = false // JW /** * Whether or not we've locked the device after they've transferred to a new one. diff --git a/app/src/main/java/org/thoughtcrime/securesms/maps/PlacePickerActivity.java b/app/src/main/java/org/thoughtcrime/securesms/maps/PlacePickerActivity.java index e34884e83a5..c8afa4a0388 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/maps/PlacePickerActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/maps/PlacePickerActivity.java @@ -16,6 +16,7 @@ import android.os.Bundle; import android.view.View; import android.view.animation.OvershootInterpolator; +import android.widget.Button; // JW import androidx.annotation.ColorInt; import androidx.annotation.NonNull; @@ -41,6 +42,7 @@ import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; // JW: added import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; import java.io.IOException; @@ -74,6 +76,10 @@ public final class PlacePickerActivity extends AppCompatActivity { private LatLng currentLocation = new LatLng(0, 0); private AddressLookup addressLookup; private GoogleMap googleMap; + // JW: added buttons + private Button btnMapTypeNormal; + private Button btnMapTypeSatellite; + private Button btnMapTypeTerrain; public static void startActivityForResultAtCurrentLocation(@NonNull Fragment fragment, int requestCode, @ColorInt int chatColor) { fragment.startActivityForResult(new Intent(fragment.requireActivity(), PlacePickerActivity.class).putExtra(KEY_CHAT_COLOR, chatColor), requestCode); @@ -94,10 +100,36 @@ public void onCreate(@Nullable Bundle savedInstanceState) { bottomSheet = findViewById(R.id.bottom_sheet); View markerImage = findViewById(R.id.marker_image_view); View fab = findViewById(R.id.place_chosen_button); + // JW: add maptype buttons + btnMapTypeNormal = findViewById(R.id.btnMapTypeNormal); + btnMapTypeSatellite = findViewById(R.id.btnMapTypeSatellite); + btnMapTypeTerrain = findViewById(R.id.btnMapTypeTerrain); ViewCompat.setBackgroundTintList(fab, ColorStateList.valueOf(getIntent().getIntExtra(KEY_CHAT_COLOR, Color.RED))); fab.setOnClickListener(v -> finishWithAddress()); + // JW: button event handlers + btnMapTypeNormal.setOnClickListener(new View.OnClickListener() { + @Override public void onClick(@NonNull View v) { + googleMap.setMapType(GoogleMap.MAP_TYPE_NORMAL); + TextSecurePreferences.setGoogleMapType(getApplicationContext(), "normal"); + } + }); + + btnMapTypeSatellite.setOnClickListener(new View.OnClickListener() { + @Override public void onClick(@NonNull View v) { + googleMap.setMapType(GoogleMap.MAP_TYPE_SATELLITE); + TextSecurePreferences.setGoogleMapType(getApplicationContext(), "satellite"); + } + }); + + btnMapTypeTerrain.setOnClickListener(new View.OnClickListener() { + @Override public void onClick(@NonNull View v) { + googleMap.setMapType(GoogleMap.MAP_TYPE_TERRAIN); + TextSecurePreferences.setGoogleMapType(getApplicationContext(), "terrain"); + } + }); + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) { @@ -167,10 +199,31 @@ private void setInitialLocation(@NonNull LatLng latLng) { private void setMap(GoogleMap googleMap) { this.googleMap = googleMap; + // JW: set maptype + if (googleMap != null) { + googleMap.setMapType(getGoogleMapType()); + } else { + // In case there is no Google maps installed: + btnMapTypeNormal.setVisibility(View.GONE); + btnMapTypeSatellite.setVisibility(View.GONE); + btnMapTypeTerrain.setVisibility(View.GONE); + } moveMapToInitialIfPossible(); } + // JW: get the maptype + public int getGoogleMapType() { + switch (TextSecurePreferences.getGoogleMapType(getApplicationContext())) { + case "hybrid": return GoogleMap.MAP_TYPE_HYBRID; + case "satellite": return GoogleMap.MAP_TYPE_SATELLITE; + case "terrain": return GoogleMap.MAP_TYPE_TERRAIN; + case "none": return GoogleMap.MAP_TYPE_NONE; + default: return GoogleMap.MAP_TYPE_NORMAL; + } + } + + private void moveMapToInitialIfPossible() { if (initialLocation != null && googleMap != null) { Log.d(TAG, "Moving map to initial location"); @@ -192,6 +245,7 @@ private void finishWithAddress() { SimpleProgressDialog.DismissibleDialog dismissibleDialog = SimpleProgressDialog.showDelayed(this); MapView mapView = findViewById(R.id.map_view); + SignalMapView.mapType = getGoogleMapType(); // JW SignalMapView.snapshot(currentLocation, mapView).addListener(new ListenableFuture.Listener<>() { @Override public void onSuccess(Bitmap result) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java index a0c7a71a30b..76517928c24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -115,7 +115,7 @@ private static Map buildDisplayOrder(@NonNull Context put(Event.BACKUP_SCHEDULE_PERMISSION, shouldShowBackupSchedulePermissionMegaphone(context) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(3)) : NEVER); put(Event.ONBOARDING, shouldShowOnboardingMegaphone(context) ? ALWAYS : NEVER); put(Event.TURN_OFF_CENSORSHIP_CIRCUMVENTION, shouldShowTurnOffCircumventionMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(7)) : NEVER); - put(Event.REMOTE_MEGAPHONE, shouldShowRemoteMegaphone(records) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(1)) : NEVER); + //put(Event.REMOTE_MEGAPHONE, shouldShowRemoteMegaphone(records) ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(1)) : NEVER); // JW put(Event.LINKED_DEVICE_INACTIVE, shouldShowLinkedDeviceInactiveMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(3)): NEVER); put(Event.PIN_REMINDER, new SignalPinReminderSchedule()); put(Event.SET_UP_YOUR_USERNAME, shouldShowSetUpYourUsernameMegaphone(records) ? ALWAYS : NEVER); @@ -156,8 +156,8 @@ private static boolean shouldShowLinkedDeviceInactiveMegaphone() { return buildTurnOffCircumventionMegaphone(context); case LINKED_DEVICE_INACTIVE: return buildLinkedDeviceInactiveMegaphone(context); - case REMOTE_MEGAPHONE: - return buildRemoteMegaphone(context); +// case REMOTE_MEGAPHONE: +// return buildRemoteMegaphone(context); // JW case BACKUP_SCHEDULE_PERMISSION: return buildBackupPermissionMegaphone(context); case SET_UP_YOUR_USERNAME: diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt index 8ca06d701e7..39c379fc631 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt @@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.database.MessageType import org.thoughtcrime.securesms.database.NoSuchMessageException import org.thoughtcrime.securesms.database.PaymentTable.PublicKeyConflictException import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.reactions // JW import org.thoughtcrime.securesms.database.model.GroupRecord import org.thoughtcrime.securesms.database.model.Mention import org.thoughtcrime.securesms.database.model.MessageId @@ -83,6 +84,7 @@ import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.mms.QuoteModel import org.thoughtcrime.securesms.mms.StickerSlide import org.thoughtcrime.securesms.notifications.v2.ConversationId +import org.thoughtcrime.securesms.notifications.v2.ConversationId.Companion.fromMessageRecord // JW import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient.HiddenState import org.thoughtcrime.securesms.recipients.RecipientId @@ -571,6 +573,17 @@ object DataMessageProcessor { return targetMessageId } + // JW: add a reaction to a message. Thanks ClauZ for the implementation + fun setMessageReaction(context: Context, message: DataMessage, targetMessage: MessageRecord?, reaction: String) { + if (targetMessage != null) { + val reactionEmoji = EmojiUtil.getCanonicalRepresentation(reaction) + val targetMessageId = MessageId(targetMessage.id) + val reactionRecord = ReactionRecord(reactionEmoji, Recipient.self().id, message.timestamp!!, System.currentTimeMillis()) + reactions.addReaction(targetMessageId, reactionRecord) + AppDependencies.messageNotifier.updateNotification(context, fromMessageRecord(targetMessage)) + } + } + fun handleRemoteDelete(context: Context, envelope: Envelope, message: DataMessage, senderRecipientId: RecipientId, earlyMessageCacheEntry: EarlyMessageCacheEntry?): MessageId? { val delete = message.delete!! @@ -579,6 +592,9 @@ object DataMessageProcessor { val targetSentTimestamp: Long = delete.targetSentTimestamp!! val targetMessage: MessageRecord? = SignalDatabase.messages.getMessageFor(targetSentTimestamp, senderRecipientId) + // JW: set a reaction to indicate the message was attempted to be remote deleted. Sender is myself, emoji is an exclamation. + if (TextSecurePreferences.isIgnoreRemoteDelete(context)) { setMessageReaction(context, message, targetMessage, "\u2757"); return null; } + return if (targetMessage != null && MessageConstraintsUtil.isValidRemoteDeleteReceive(targetMessage, senderRecipientId, envelope.serverTimestamp!!)) { SignalDatabase.messages.markAsRemoteDelete(targetMessage) if (targetMessage.isStory()) { @@ -894,6 +910,7 @@ object DataMessageProcessor { notifyTypingStoppedFromIncomingMessage(context, senderRecipient, threadRecipient.id, metadata.sourceDeviceId) val insertResult: InsertResult? + val viewOnce: Boolean = if (TextSecurePreferences.isKeepViewOnceMessages(context)) false else (message.isViewOnce == true) // JW SignalDatabase.messages.beginTransaction() try { @@ -914,7 +931,7 @@ object DataMessageProcessor { serverTimeMillis = envelope.serverTimestamp!!, receivedTimeMillis = receivedTime, expiresIn = message.expireTimerDuration.inWholeMilliseconds, - isViewOnce = message.isViewOnce == true, + isViewOnce = viewOnce, // JW isUnidentified = metadata.sealedSender, body = message.body?.ifEmpty { null }, groupId = groupId, @@ -958,7 +975,12 @@ object DataMessageProcessor { AppDependencies.messageNotifier.updateNotification(context, ConversationId.forConversation(insertResult.threadId)) TrimThreadJob.enqueueAsync(insertResult.threadId) - if (message.isViewOnce == true) { + // JW: add a [1] reaction to indicate the message was sent as viewOnce. + if (TextSecurePreferences.isKeepViewOnceMessages(context) && (message.isViewOnce == true)) { + val targetMessage = SignalDatabase.messages.getMessageRecordOrNull(insertResult.messageId) + setMessageReaction(context, message, targetMessage, "\u0031\uFE0F\u20E3") + } + if (viewOnce) { // JW AppDependencies.viewOnceMessageManager.scheduleIfNecessary() } } @@ -1085,7 +1107,15 @@ object DataMessageProcessor { return null } - val authorId = Recipient.externalPush(ServiceId.parseOrThrow(quote.authorAci!!)).id + // JW: copied this patch from Molly + //val authorId = Recipient.externalPush(ServiceId.parseOrThrow(quote.authorAci!!)).id + val authorAci = ServiceId.parseOrThrow(quote.authorAci!!) + if (authorAci.isUnknown) { + warn(timestamp, "Received quote with an unknown author UUID! Ignoring...") + return null + } + val authorId = Recipient.externalPush(authorAci).id + //-------------------------------- var quotedMessage = SignalDatabase.messages.getMessageFor(quote.id!!, authorId) as? MmsMessageRecord if (quotedMessage != null && !quotedMessage.isRemoteDelete) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt index f5ef7ca7f7d..5bbeae5dcf9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/SyncMessageProcessor.kt @@ -814,7 +814,7 @@ object SyncMessageProcessor { val previews: List = DataMessageProcessor.getLinkPreviews(dataMessage.preview, dataMessage.body ?: "", false) val mentions: List = DataMessageProcessor.getMentions(dataMessage.bodyRanges) val giftBadge: GiftBadge? = if (dataMessage.giftBadge?.receiptCredentialPresentation != null) GiftBadge.Builder().redemptionToken(dataMessage.giftBadge!!.receiptCredentialPresentation!!).build() else null - val viewOnce: Boolean = dataMessage.isViewOnce == true + val viewOnce: Boolean = if (TextSecurePreferences.isKeepViewOnceMessages(context)) false else (dataMessage.isViewOnce === true) // JW val bodyRanges: BodyRangeList? = dataMessage.bodyRanges.toBodyRangeList() val syncAttachments: List = listOfNotNull(sticker) + if (viewOnce) listOf(TombstoneAttachment(MediaUtil.VIEW_ONCE, false)) else dataMessage.attachments.toPointersWithinLimit() @@ -1019,6 +1019,7 @@ object SyncMessageProcessor { } private fun handleSynchronizeViewOnceOpenMessage(context: Context, openMessage: ViewOnceOpen, envelopeTimestamp: Long, earlyMessageCacheEntry: EarlyMessageCacheEntry?) { + if (TextSecurePreferences.isKeepViewOnceMessages(context)) return // JW log(envelopeTimestamp, "Handling a view-once open for message: " + openMessage.timestamp) val author: RecipientId = Recipient.externalPush(ServiceId.parseOrThrow(openMessage.senderAci!!)).id diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java index bd7f86529f5..a7b603c0187 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AttachmentUtil.java @@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.database.NoSuchMessageException; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.dependencies.AppDependencies; // JW: added import org.thoughtcrime.securesms.jobmanager.impl.NotInCallConstraint; import org.thoughtcrime.securesms.jobs.MultiDeviceDeleteSyncJob; import org.thoughtcrime.securesms.recipients.Recipient; @@ -100,8 +101,14 @@ public static boolean isAutoDownloadPermitted(@NonNull Context context, @Nullabl MessageRecord deletedMessageRecord = null; if (attachmentCount <= 1) { + // JW: changed deletedMessageRecord = SignalDatabase.messages().getMessageRecordOrNull(mmsId); - SignalDatabase.messages().deleteMessage(mmsId); + if (!TextSecurePreferences.isDeleteMediaOnly(AppDependencies.getApplication())) { + SignalDatabase.messages().deleteMessage(mmsId); + } else { + SignalDatabase.messages().deleteAttachmentsOnly(mmsId); + deletedMessageRecord = null; // JW: don't propagate this delete to linked devices here + } } else { SignalDatabase.attachments().deleteAttachment(attachmentId); if (Recipient.self().getDeleteSyncCapability().isSupported()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java index 1d56cea8efa..67085e41419 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java @@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.TextSecurePreferences; // JW: added import java.io.File; import java.security.SecureRandom; @@ -50,7 +51,9 @@ public class BackupUtil { } public static boolean isUserSelectionRequired(@NonNull Context context) { - return Build.VERSION.SDK_INT >= 29 && !Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE); + // JW: changed this because we need the manifest WRITE permission for the preservelegacy flag. + //return Build.VERSION.SDK_INT >= 29 && !Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE); + return Build.VERSION.SDK_INT >= 29 && !Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE); } public static boolean canUserAccessBackupDirectory(@NonNull Context context) { @@ -189,6 +192,24 @@ private static List getAllBackupsNewestFirstApi29() { private static List getAllBackupsNewestFirstLegacy() throws NoExternalStorageException { File backupDirectory = StorageUtil.getOrCreateBackupDirectory(); File[] files = backupDirectory.listFiles(); + // JW: if no backup found in internal storage, try removable storage. + // This code is used at first app start when restoring a backup that is located + // on the removable storage. + if (files.length == 0) { + Context context = AppDependencies.getApplication(); + // This code should run only at the initial app start. In that case isBackupLocationChanged + // defaults to false. + if (!TextSecurePreferences.isBackupLocationChanged(context)) { + TextSecurePreferences.setBackupLocationRemovable(context, true); + TextSecurePreferences.setBackupLocationChanged(context, true); // Set this so we know it has been changed in the future + backupDirectory = StorageUtil.getBackupDirectory(); + files = backupDirectory.listFiles(); + if (files.length == 0) { // No backup in removable storage, reset preferences to default values + TextSecurePreferences.setBackupLocationRemovable(context, false); + TextSecurePreferences.setBackupLocationChanged(context, false); + } + } + } List backups = new ArrayList<>(files.length); for (File file : files) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FileUtilsJW.java b/app/src/main/java/org/thoughtcrime/securesms/util/FileUtilsJW.java new file mode 100644 index 00000000000..6e17796e95f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FileUtilsJW.java @@ -0,0 +1,150 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; + +import net.lingala.zip4j.ZipFile; +import net.lingala.zip4j.exception.ZipException; +import net.lingala.zip4j.model.ZipParameters; +import net.lingala.zip4j.model.enums.AesKeyStrength; +import net.lingala.zip4j.model.enums.CompressionLevel; +import net.lingala.zip4j.model.enums.CompressionMethod; +import net.lingala.zip4j.model.enums.EncryptionMethod; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.backup.BackupPassphrase; +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.lang.String; +import java.security.SecureRandom; + + +public class FileUtilsJW { + + private static final String TAG = Log.tag(FileUtilsJW.class); + + //------------------------------------------------------------------------------------------------ + // Handle backups in encrypted zipfiles + public static boolean createEncryptedZipfile(Context context, String zipFileName, String exportDirectory, String exportSecretsDirectory) { + try { + String password = getBackupPassword(context); + ZipFile zipFile = new ZipFile(zipFileName); + ZipParameters parameters = new ZipParameters(); + parameters.setCompressionMethod(CompressionMethod.STORE); // Encrypted data is uncompressable anyway + //parameters.setCompressionLevel(CompressionLevel.FASTEST); + if (password.length() > 0 ) { + parameters.setEncryptFiles(true); + parameters.setEncryptionMethod(EncryptionMethod.AES); + parameters.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_256); + zipFile.setPassword(password.toCharArray()); + } + zipFile.addFolder(new File(exportSecretsDirectory), parameters); + zipFile.addFolder(new File(exportDirectory), parameters); + } catch (ZipException e) { + Log.w(TAG, "createEncryptedZipfile failed: " + e.toString()); + return false; + } + return true; + } + + public static boolean createEncryptedPlaintextZipfile(Context context, String zipFileName, String inputFileName) { + try { + String password = getBackupPassword(context); + ZipFile zipFile = new ZipFile(zipFileName); + ZipParameters parameters = new ZipParameters(); + parameters.setCompressionLevel(CompressionLevel.MAXIMUM); + if (password.length() > 0 ) { + parameters.setEncryptFiles(true); + parameters.setEncryptionMethod(EncryptionMethod.AES); + parameters.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_256); + zipFile.setPassword(password.toCharArray()); + } + zipFile.addFile(inputFileName, parameters); + } catch (ZipException e) { + Log.w(TAG, "createEncryptedPlaintextZipfile failed: " + e.toString()); + return false; + } + return true; + } + + // Get the password of the regular backup. If there is no regular backup set, return an empty string. + public static String getBackupPassword(Context context) { + String password = ""; + Boolean chatBackupsEnabled = SignalStore.settings().isBackupEnabled(); + if (chatBackupsEnabled) { + password = BackupPassphrase.get(context); + if (password == null) { + Log.w(TAG, "createEncryptedZipfile: empty zipfile password"); + password = ""; + } + // Plaintext storage of password may contain spaces + password = password.replace(" ", ""); + } + return password; + } + + public static boolean extractEncryptedZipfile(Context context, String fileName, String directoryName) { + String password = getBackupPassword(context); + + try { + ZipFile zipFile = new ZipFile(fileName); + if (zipFile.isEncrypted()) { + zipFile.setPassword(password.toCharArray()); + } + zipFile.extractAll(directoryName); + } catch (Exception e) { + Log.w(TAG, "extractEncryptedZipfile failed: " + e.toString()); + return false; + } + return true; + } + //------------------------------------------------------------------------------------------------ + + public static void deleteRecursive(File fileOrDirectory) { + if (fileOrDirectory.isDirectory()) { + for (File child : fileOrDirectory.listFiles()) { + deleteRecursive(child); + } + } + fileOrDirectory.delete(); + } + + public static void secureDeleteRecursive(File fileOrDirectory) { + if (fileOrDirectory.isDirectory()) { + for (File child : fileOrDirectory.listFiles()) { + secureDeleteRecursive(child); + } + } + try { + if (!fileOrDirectory.isFile()) { + fileOrDirectory.delete(); + } else { + secureDelete(fileOrDirectory); + } + } catch (IOException e) { + Log.w(TAG, "secureDeleteRecursive failed: " + e.toString()); + } + } + + // Not perfect on wear-leveling flash memory but still better than nothing. + public static void secureDelete(File file) throws IOException { + if (file.exists()) { + long length = file.length(); + SecureRandom random = new SecureRandom(); + RandomAccessFile raf = new RandomAccessFile(file, "rws"); + raf.seek(0); + raf.getFilePointer(); + byte[] data = new byte[64]; + long pos = 0; + while (pos < length) { + random.nextBytes(data); + raf.write(data); + pos += data.length; + } + raf.close(); + file.delete(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index f2c2a708423..7d1121eaf6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -505,12 +505,7 @@ object RemoteConfig { /** Whether or not the user is an 'internal' one, which activates certain developer tools. */ @JvmStatic @get:JvmName("internalUser") - val internalUser: Boolean by remoteValue( - key = "android.internalUser", - hotSwappable = true - ) { value -> - value.asBoolean(false) || Environment.IS_NIGHTLY || Environment.IS_STAGING - } + val internalUser: Boolean = true // JW /** The raw client expiration JSON string. */ @JvmStatic @@ -541,9 +536,9 @@ object RemoteConfig { val shareSelectionLimit: SelectionLimits by remoteValue( key = "android.share.limit", - hotSwappable = true + hotSwappable = false // JW ) { value -> - val limit = value.asInteger(5) + val limit = Integer.MAX_VALUE // JW: no forward limit SelectionLimits(limit, limit) } @@ -1105,11 +1100,7 @@ object RemoteConfig { /** Whether or not to show chat folders. */ @JvmStatic - val showChatFolders: Boolean by remoteBoolean( - key = "android.showChatFolders", - defaultValue = false, - hotSwappable = true - ) + val showChatFolders: Boolean = true // JW @JvmStatic @get:JvmName("newCallUi") diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java index 728c4a88913..ff85ad26172 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StorageUtil.java @@ -8,34 +8,78 @@ import android.os.storage.StorageManager; import android.os.storage.StorageVolume; import android.provider.MediaStore; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; +import com.annimon.stream.Stream; // JW: added + import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.NoExternalStorageException; import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.permissions.PermissionCompat; +import org.thoughtcrime.securesms.keyvalue.SignalStore; // JW: added import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.util.UriUtils; // JW: added import java.io.File; +import java.nio.file.Path; // JW: added import java.util.List; import java.util.Objects; public class StorageUtil { private static final String PRODUCTION_PACKAGE_ID = "org.thoughtcrime.securesms"; + // JW: the different backup types + private static final String BACKUPS = "Backups"; + private static final String FULL_BACKUPS = "FullBackups"; + private static final String PLAINTEXT_BACKUPS = "PlaintextBackups"; - public static File getOrCreateBackupDirectory() throws NoExternalStorageException { - File storage = Environment.getExternalStorageDirectory(); + // JW: split backup directories per type because otherwise some files might get unintentionally deleted + public static File getBackupDirectory() throws NoExternalStorageException { + if (Build.VERSION.SDK_INT >= 30) { + // We don't add the separate "Backups" subdir for Android 11+ to not complicate things... + return getBackupTypeDirectory(""); + } else { + return getBackupTypeDirectory(BACKUPS); + } + } - if (!storage.canWrite()) { - throw new NoExternalStorageException(); + public static File getBackupPlaintextDirectory() throws NoExternalStorageException { + return getBackupTypeDirectory(PLAINTEXT_BACKUPS); + } + + public static File getRawBackupDirectory() throws NoExternalStorageException { + return getBackupTypeDirectory(FULL_BACKUPS); + } + + private static File getBackupTypeDirectory(String backupType) throws NoExternalStorageException { + Context context = AppDependencies.getApplication(); + File signal = null; + if (Build.VERSION.SDK_INT < 30) { + signal = getBackupBaseDirectory(); + } else { + Uri backupDirectoryUri = SignalStore.settings().getSignalBackupDirectory(); + signal = new File(UriUtils.getFullPathFromTreeUri(context, backupDirectoryUri)); + } + // For android 11+, if the last part ends with "Backups", remove that and add the backupType so + // we still can use the Backups, FulBackups etc. subdirectories when the chosen backup folder + // is a subdirectory called Backups. + if (Build.VERSION.SDK_INT >= 30 && !backupType.equals("")) { + Path selectedDir = signal.toPath(); + if (selectedDir.endsWith(BACKUPS)) { + signal = selectedDir.getParent().toFile(); + } } + File backups = new File(signal, backupType); - File backups = getBackupDirectory(); + //noinspection ConstantConditions + if (BuildConfig.APPLICATION_ID.startsWith(PRODUCTION_PACKAGE_ID + ".")) { + backups = new File(backups, BuildConfig.APPLICATION_ID.substring(PRODUCTION_PACKAGE_ID.length() + 1)); + } if (!backups.exists()) { if (!backups.mkdirs()) { @@ -64,26 +108,72 @@ public static File getOrCreateBackupV2Directory() throws NoExternalStorageExcept return backups; } - public static File getBackupDirectory() throws NoExternalStorageException { + public static File getBackupV2Directory() throws NoExternalStorageException { File storage = Environment.getExternalStorageDirectory(); - File signal = new File(storage, "Signal"); - File backups = new File(signal, "Backups"); + File backups = new File(storage, "Signal"); //noinspection ConstantConditions if (BuildConfig.APPLICATION_ID.startsWith(PRODUCTION_PACKAGE_ID + ".")) { - backups = new File(backups, BuildConfig.APPLICATION_ID.substring(PRODUCTION_PACKAGE_ID.length() + 1)); + backups = new File(storage, BuildConfig.APPLICATION_ID.substring(PRODUCTION_PACKAGE_ID.length() + 1)); } return backups; } - public static File getBackupV2Directory() throws NoExternalStorageException { - File storage = Environment.getExternalStorageDirectory(); - File backups = new File(storage, "Signal"); + // JW: added. Returns storage dir on internal or removable storage + private static File getStorage() throws NoExternalStorageException { + Context context = AppDependencies.getApplication(); + File storage = null; + + // We now check if the removable storage is prefered. If it is + // and it is not available we fallback to internal storage. + if (TextSecurePreferences.isBackupLocationRemovable(context)) { + // For now we only support the application directory on the removable storage. + if (Build.VERSION.SDK_INT >= 19) { + File[] directories = context.getExternalFilesDirs(null); + + if (directories != null) { + storage = Stream.of(directories) + .withoutNulls() + .filterNot(f -> f.getAbsolutePath().contains("emulated")) + .limit(1) + .findSingle() + .orElse(null); + } + } + } + if (storage == null) { + storage = Environment.getExternalStorageDirectory(); + } + return storage; + } - //noinspection ConstantConditions - if (BuildConfig.APPLICATION_ID.startsWith(PRODUCTION_PACKAGE_ID + ".")) { - backups = new File(storage, BuildConfig.APPLICATION_ID.substring(PRODUCTION_PACKAGE_ID.length() + 1)); + // JW: added method + public static File getBackupBaseDirectory() throws NoExternalStorageException { + File storage = getStorage(); + + if (!storage.canWrite()) { + throw new NoExternalStorageException(); + } + + File signal = new File(storage, "Signal"); + + return signal; + } + + public static File getOrCreateBackupDirectory() throws NoExternalStorageException { + File storage = getStorage(); // JW: changed + + if (!storage.canWrite()) { + throw new NoExternalStorageException(); + } + + File backups = getBackupDirectory(); + + if (!backups.exists()) { + if (!backups.mkdirs()) { + throw new NoExternalStorageException("Unable to create backup directory..."); + } } return backups; @@ -113,6 +203,31 @@ public static File getBackupV2Directory() throws NoExternalStorageException { } } + public static File getBackupCacheDirectory(Context context) { + // JW: changed. + if (TextSecurePreferences.isBackupLocationRemovable(context)) { + if (Build.VERSION.SDK_INT >= 19) { + File[] directories = context.getExternalCacheDirs(); + + if (directories != null) { + File result = getNonEmulated(directories); + if (result != null) return result; + } + } + } + return context.getExternalCacheDir(); + } + + // JW: re-added + private static @Nullable File getNonEmulated(File[] directories) { + return Stream.of(directories) + .withoutNulls() + .filterNot(f -> f.getAbsolutePath().contains("emulated")) + .limit(1) + .findSingle() + .orElse(null); + } + private static File getSignalStorageDir() throws NoExternalStorageException { final File storage = Environment.getExternalStorageDirectory(); @@ -135,6 +250,10 @@ public static boolean canWriteInSignalStorageDir() { return storage.canWrite(); } + public static File getLegacyBackupDirectory() throws NoExternalStorageException { + return getSignalStorageDir(); + } + public static boolean canWriteToMediaStore() { return Build.VERSION.SDK_INT > 28 || Permissions.hasAll(AppDependencies.getApplication(), Manifest.permission.WRITE_EXTERNAL_STORAGE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java index 7482d75caa2..4382078a556 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -192,6 +192,31 @@ public class TextSecurePreferences { private static final String ARGON2_TESTED = "argon2_tested"; + //--------------------------------------------------------------------------- + // JW: added strings are in this block. + // true = passphrase, false = Android lock or fingerprint + public static final String PROTECTION_METHOD_PREF = "pref_signal_protection_method"; + // true = backup to removable SD card (if available), false = backup to internal sd card + public static final String BACKUP_LOCATION_REMOVABLE_PREF = "pref_backup_location_external"; + // false (default) means the backup location is not changed by the user, true means it is changed. + // This is used to determine at first app start to locate the app backup. + public static final String BACKUP_LOCATION_CHANGED = "pref_backup_location_changed"; + // added to use encrypted zipfiles to store raw backups + public static final String BACKUP_STORE_ZIPFILE_PREF = "pref_backup_zipfile"; + // added to use encrypted zipfiles to store plaintext backups + public static final String BACKUP_STORE_ZIPFILE_PLAIN_PREF = "pref_backup_zipfile_plain"; + // used to see if we delete view once messages after view or not + public static final String KEEP_VIEW_ONCE_MESSAGES = "pref_keep_view_once_messages"; + // used to see if we ignore remote delete messages or not + public static final String IGNORE_REMOTE_DELETE = "pref_ignore_remote_delete"; + // select map type for location picker + public static final String GOOGLE_MAP_TYPE = "pref_google_map_type"; + // delete only media, not the rest of the message, from the All media screen + public static final String DELETE_MEDIA_ONLY = "pref_delete_media_only"; + // who can add you to groups + public static final String WHO_CAN_ADD_YOU_TO_GROUPS = "pref_who_can_add_you_to_groups"; + //--------------------------------------------------------------------------- + private static final String[] booleanPreferencesToBackup = {SCREEN_SECURITY_PREF, INCOGNITO_KEYBORAD_PREF, ALWAYS_RELAY_CALLS_PREF, @@ -207,7 +232,13 @@ public class TextSecurePreferences { NEW_CONTACTS_NOTIFICATIONS, SHOW_INVITE_REMINDER_PREF, SYSTEM_EMOJI_PREF, - ENTER_SENDS_PREF}; + ENTER_SENDS_PREF, + // JW: added boolean options + BACKUP_STORE_ZIPFILE_PREF, + BACKUP_STORE_ZIPFILE_PLAIN_PREF, + KEEP_VIEW_ONCE_MESSAGES, + IGNORE_REMOTE_DELETE, + DELETE_MEDIA_ONLY}; private static final String[] stringPreferencesToBackup = {LED_COLOR_PREF, LED_BLINK_PREF, @@ -215,7 +246,10 @@ public class TextSecurePreferences { NOTIFICATION_PRIVACY_PREF, THEME_PREF, LANGUAGE_PREF, - MESSAGE_BODY_TEXT_SIZE_PREF}; + MESSAGE_BODY_TEXT_SIZE_PREF, + // JW: added String options + GOOGLE_MAP_TYPE, + WHO_CAN_ADD_YOU_TO_GROUPS}; private static final String[] stringSetPreferencesToBackup = {MEDIA_DOWNLOAD_MOBILE_PREF, MEDIA_DOWNLOAD_WIFI_PREF, @@ -1167,4 +1201,88 @@ private static void notifyUnregisteredReceived(Context context) { public enum MediaKeyboardMode { EMOJI, STICKER, GIF } + + //--------------------------------------------------------------------------- + // JW: added get/set methods are in this block. + public static boolean isProtectionMethodPassphrase(@NonNull Context context) { + return getBooleanPreference(context, PROTECTION_METHOD_PREF, false); + } + + public static void setProtectionMethod(Context context, boolean value) { + setBooleanPreference(context, PROTECTION_METHOD_PREF, value); + } + + public static void setBackupLocationRemovable(Context context, boolean value) { + setBooleanPreference(context, BACKUP_LOCATION_REMOVABLE_PREF, value); + } + // Default to false so default does the same as official Signal. + public static boolean isBackupLocationRemovable(Context context) { + return getBooleanPreference(context, BACKUP_LOCATION_REMOVABLE_PREF, false); + } + + public static void setBackupLocationChanged(Context context, boolean value) { + setBooleanPreference(context, BACKUP_LOCATION_CHANGED, value); + } + + public static boolean isBackupLocationChanged(Context context) { + return getBooleanPreference(context, BACKUP_LOCATION_CHANGED, false); + } + + public static boolean isRawBackupInZipfile(Context context) { + return getBooleanPreference(context, BACKUP_STORE_ZIPFILE_PREF, false); + } + + public static void setRawBackupZipfile(Context context, boolean value) { + setBooleanPreference(context, BACKUP_STORE_ZIPFILE_PREF, value); + } + + public static boolean isPlainBackupInZipfile(Context context) { + return getBooleanPreference(context, BACKUP_STORE_ZIPFILE_PLAIN_PREF, false); + } + + public static void setPlainBackupZipfile(Context context, boolean value) { + setBooleanPreference(context, BACKUP_STORE_ZIPFILE_PLAIN_PREF, value); + } + + public static boolean isKeepViewOnceMessages(Context context) { + return getBooleanPreference(context, KEEP_VIEW_ONCE_MESSAGES, false); + } + + public static void setKeepViewOnceMessages(Context context, boolean value) { + setBooleanPreference(context, KEEP_VIEW_ONCE_MESSAGES, value); + } + + public static String getGoogleMapType(Context context) { + return getStringPreference(context, GOOGLE_MAP_TYPE, "normal"); + } + + public static void setGoogleMapType(Context context, String value) { + setStringPreference(context, GOOGLE_MAP_TYPE, value); + } + + public static boolean isIgnoreRemoteDelete(Context context) { + return getBooleanPreference(context, IGNORE_REMOTE_DELETE, false); + } + + public static void setIgnoreRemoteDelete(Context context, boolean value) { + setBooleanPreference(context, IGNORE_REMOTE_DELETE, value); + } + + public static boolean isDeleteMediaOnly(Context context) { + return getBooleanPreference(context, DELETE_MEDIA_ONLY, false); + } + + public static void setDeleteMediaOnly(Context context, boolean value) { + setBooleanPreference(context, DELETE_MEDIA_ONLY, value); + } + + public static String whoCanAddYouToGroups(Context context) { + return getStringPreference(context, WHO_CAN_ADD_YOU_TO_GROUPS, "nonblocked"); + } + + public static void setWhoCanAddYouToGroups(Context context, String value) { + setStringPreference(context, WHO_CAN_ADD_YOU_TO_GROUPS, value); + } + // End added methods block + //--------------------------------------------------------------------------- } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/UriUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/UriUtils.java new file mode 100644 index 00000000000..7f5b786466b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/UriUtils.java @@ -0,0 +1,119 @@ +// https://stackoverflow.com/questions/34927748/android-5-0-documentfile-from-tree-uri + +package org.thoughtcrime.securesms.util; + +import androidx.annotation.Nullable; +import android.annotation.TargetApi; +import android.os.Build; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; +import android.provider.DocumentsContract; +import android.content.Context; +import android.net.Uri; +import java.io.File; +import java.lang.reflect.Array; +import java.lang.reflect.Method; +import java.util.List; + + +public class UriUtils { + + private static final String PRIMARY_VOLUME_NAME = "primary"; + + public static String getFullPathFromTreeUri(Context context, @Nullable final Uri treeUri) { + if (treeUri == null) return ""; + String volumePath = getVolumePath(getVolumeIdFromTreeUri(treeUri), context); + if (volumePath == null) return File.separator; + if (volumePath.endsWith(File.separator)) + volumePath = volumePath.substring(0, volumePath.length() - 1); + + String documentPath = getDocumentPathFromTreeUri(treeUri); + if (documentPath.endsWith(File.separator)) + documentPath = documentPath.substring(0, documentPath.length() - 1); + + if (documentPath.length() > 0) { + if (documentPath.startsWith(File.separator)) + return volumePath + documentPath; + else + return volumePath + File.separator + documentPath; + } + else return volumePath; + } + + private static String getVolumePath(final String volumeId, Context context) { + if (Build.VERSION.SDK_INT < 21) + return null; + if (Build.VERSION.SDK_INT >= 30) + return getVolumePathForAndroid11AndAbove(volumeId, context); + else + return getVolumePathBeforeAndroid11(volumeId, context); + } + + private static String getVolumePathBeforeAndroid11(final String volumeId, Context context){ + try { + StorageManager mStorageManager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); + Class storageVolumeClazz = Class.forName("android.os.storage.StorageVolume"); + Method getVolumeList = mStorageManager.getClass().getMethod("getVolumeList"); + Method getUuid = storageVolumeClazz.getMethod("getUuid"); + Method getPath = storageVolumeClazz.getMethod("getPath"); + Method isPrimary = storageVolumeClazz.getMethod("isPrimary"); + Object result = getVolumeList.invoke(mStorageManager); + + final int length = Array.getLength(result); + for (int i = 0; i < length; i++) { + Object storageVolumeElement = Array.get(result, i); + String uuid = (String) getUuid.invoke(storageVolumeElement); + Boolean primary = (Boolean) isPrimary.invoke(storageVolumeElement); + + if (primary && PRIMARY_VOLUME_NAME.equals(volumeId)) // primary volume? + return (String) getPath.invoke(storageVolumeElement); + + if (uuid != null && uuid.equals(volumeId)) // other volumes? + return (String) getPath.invoke(storageVolumeElement); + } + // not found. + return null; + } catch (Exception ex) { + return null; + } + } + + @TargetApi(30) + private static String getVolumePathForAndroid11AndAbove(final String volumeId, Context context) { + try { + StorageManager mStorageManager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); + List storageVolumes = mStorageManager.getStorageVolumes(); + for (StorageVolume storageVolume : storageVolumes) { + // primary volume? + if (storageVolume.isPrimary() && PRIMARY_VOLUME_NAME.equals(volumeId)) + return storageVolume.getDirectory().getPath(); + + // other volumes? + String uuid = storageVolume.getUuid(); + if (uuid != null && uuid.equals(volumeId)) + return storageVolume.getDirectory().getPath(); + + } + // not found. + return null; + } catch (Exception ex) { + return null; + } + } + + @TargetApi(21) + private static String getVolumeIdFromTreeUri(final Uri treeUri) { + final String docId = DocumentsContract.getTreeDocumentId(treeUri); + final String[] split = docId.split(":"); + if (split.length > 0) return split[0]; + else return null; + } + + @TargetApi(21) + private static String getDocumentPathFromTreeUri(final Uri treeUri) { + final String docId = DocumentsContract.getTreeDocumentId(treeUri); + final String[] split = docId.split(":"); + if ((split.length >= 2) && (split[1] != null)) return split[1]; + else return File.separator; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index b345b92dcc1..cdceebc76b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -350,20 +350,8 @@ public static byte[] getSecretBytes(@NonNull SecureRandom secureRandom, int size * Takes into account both the build age as well as any remote deprecation values. */ public static long getTimeUntilBuildExpiry(long currentTime) { - if (SignalStore.misc().isClientDeprecated()) { - return 0; - } - - long buildAge = currentTime - BuildConfig.BUILD_TIMESTAMP; - long timeUntilBuildDeprecation = BUILD_LIFESPAN - buildAge; - long timeUntilRemoteDeprecation = RemoteDeprecation.getTimeUntilDeprecation(currentTime); - - if (timeUntilRemoteDeprecation != -1) { - long timeUntilDeprecation = Math.min(timeUntilBuildDeprecation, timeUntilRemoteDeprecation); - return Math.max(timeUntilDeprecation, 0); - } else { - return Math.max(timeUntilBuildDeprecation, 0); - } + // JW never expire builds. This is an ugly hack but it prevents me from making changes all over the code with each new release. + return Integer.MAX_VALUE; } public static T getRandomElement(T[] elements) { diff --git a/app/src/main/res/drawable-hdpi/ic_content_copy_white_24dp.webp b/app/src/main/res/drawable-hdpi/ic_content_copy_white_24dp.webp new file mode 100644 index 0000000000000000000000000000000000000000..51776625b02af2b922f6e3c8e0740588fb06d7f3 GIT binary patch literal 156 zcmV;N0Av4BNk&GL00012MM6+kP&iD70000lBftm{r!fEXKmTk404$q}&Dksi;1CHA z4G5CaYn2tNoS94<&eKsa*S$dS4yht944zjgrEY~EW$|0lpb?6?O*X|7mGg-2)* z3?;zb|4`!|kZ3dO*9Z19W-%K(nj`^uL!ry@ByM(W_0t zJg#=*x>f`GKd>JR4q_{V$XH87L`1Qal2fIkTq3nLh|G-?4#VLQVs(GlmFrS%!ZoU< MkFOk;kyh|k0wldT1B8JTOS+@4BLl<6e(pbstUx|flDE4H z!~gdFGy8!&_7YEDSN5lzyj(mA@3RZ^fkKS=MSdZS=NFaF0ZtomlZXII42gxeGAxB7AbPAyz=IeO7Q03soO&3ZB-oN653=|$gWuaE4lAm#fQ}o-*hK=s{t--9g> zfa3ssmcynP*E>wk2t`C>Fy!0^d+_th!9=e!ny@Q=ujXq-=JJK?-l7nxj3AP2B`H&x Qzx}K^%a>XHE#|9j0N5HtJ^%m! literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/whatsapp.png b/app/src/main/res/drawable-mdpi/whatsapp.png new file mode 100644 index 0000000000000000000000000000000000000000..295557dd527eb801538cbd3a39dc9e80db3dc6b7 GIT binary patch literal 420 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_*1mUKs7M+SzC{oH>NS%G|}ByV>Y zhX3vTXZ8bm>?NMQuIx`adAYcaRi-RA0}3(b7x{%So?lcx3&=U`>Eak-ae8aWLEb|K zJYP!L_vsv-ZXayGXkEftr^D>6@G*(G#71U$-((Xlp{*fHzg>NP$CWYlwsG!}@AVZW zD-H%ab?6*QC#6Ig*Coi6b1^)k^PBXouH9h3Ca&zIW9mZ?@3>gl8Tg9CQH{m;+iM87lou+vm56w_pe;feXlhZBUVbBLe`rq&2_* literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_lock_white_24dp.webp b/app/src/main/res/drawable-xhdpi/ic_lock_white_24dp.webp new file mode 100644 index 0000000000000000000000000000000000000000..e4c0c95f828a22904aef55509e5aa81aa02dee96 GIT binary patch literal 328 zcmV-O0k{5ANk&FM0RRA3MM6+kP&iC90RR9mFTe{BCt@INoA@_4prN>4YS%QyaRCq01Tl(NB}hez=i;FmvCROd>DWa;WdXb1dN;seihW-1n2;# zy$lZmFkDHJY+7^mY}>Z&*Zbep#kq4HKO_1-0rY{@aJ99+-}lW*NR^{FdBE~9&dcDh z!*R{WSHGIho6XsDt#PSE02t(SBE2vKfV|2Ro&e|t$gBkW0LCGn>H`=A$Tl)YcJ#f+;kZ}QaJa=G2j<>*#2f)nP0>HPVC3{D>e(WYRvE&-8tGHMX%pI{K z*Fg;I|Apr?NS%G|}ByV>Y zhX3vTXZ8bm>?NMQuIx`a`PlVEWo7Eaj?arkNY+8|~_ zfqNwe8Ga7}YMJ61X5Pv&u;5v?jxRnkuzPh%1K@yW;QxL==vkj^4qh_e0GfW zWl1x&>8I=Nr?f8`&`O!s{XNyte47#_9#KZ{PN)bgb(jh`FDl=0jX zIIw#|Y`M=)!5+aLo8#`%QT&T`@s+V=TiZ;Wef2K)xhT{0H%iZ@PBX83v{i1xiA7Zk zPo)f{?s~8o?$B;uU&=H)_}$J^1=Htu>$XT7`EdG9gZsiXjyyr_l$IE^8-mQOIb8WF z&H~JP7fe|Y>AYpK``UjqZ|duEep){@c;D^043VLc%%-~!#$EI(bDNbA)pje^UmzvX zDRjn)l@fono>*#{v)!JMab8bp(W-eS++{yqbPSjE9=pX@*wQiUa<`(yD(fU0v-Sh4@^FnMli2rd{t}$d(=U%`2g+R;q!*;Is zi&oz-uW=}d{GlXs-S6&^ZB4&_Y-U<^^@_lqr{7%)1gu^ed7j?qUv{k_l*zd1`L!3> zQ*sMLvmImjxl*3{9j#BYtx&SC*ipz-;}q~f({9~Q^}ACfWhC#-c=1qtvCM7n{~Vlk z4BC#}aV_3$_knS&TH+c}l9E`GYL#4+3Zxi}42%qQ4J>sHO+pOJtxU|Vj109646F<{9 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_content_copy_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_content_copy_white_24dp.webp new file mode 100644 index 0000000000000000000000000000000000000000..a47105458d482652bf5b617efaf98e828f3ab47d GIT binary patch literal 194 zcmV;z06qUwNk&Gx00012MM6+kP&iDk0000lN5Byf$Dkl>11A1W@2Hsn`BxK33LE`E z=rMpLIuy2TgLKTh1IPt}7!bli%j5UK^bS0ag&{N?xQ(PpJ+sU_Q~!VK?%h@Cm=w|f z39yIP70m`oS~fwIL^hvP_S$K9sF%@`sYjBQ4N${s-;v?1;sByqoY_h%Z#a!ec1(p&=$@fVci*6kpKVy literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/ic_lock_white_24dp.webp b/app/src/main/res/drawable-xxhdpi/ic_lock_white_24dp.webp new file mode 100644 index 0000000000000000000000000000000000000000..efb7f01a753a3d61aa245ad41def205f28a21e31 GIT binary patch literal 430 zcmV;f0a5-^Nk&Gd0RRA3MM6+kP&iDQ0RR9mN5Byfw_+e|oA@_-6(01QCLgxhyr{ch#LfBgwZ1@ z0{Hb9sspD&dw(beux#5hBt_Y_ZQEwM_~t*Ch|G+7(f*3){{)a9x;9iFzFaOZ!&*=y zk|qBJGGB9MVInV}@F^^YstFwHDgjh&;M!8*Yl7r;-s{b;63{>w0rom(B!m!Ce9M9` zGRq6&mQ@o;P5#b!GULhveTx#=u{#A>RN6O2mMAPRUNx9PK(EzGv^xp{nCgreQJi2@ zFb%1TQJLjN4KTe>bHZ^$J!861Jsy4Pj0xxtk1hp&3i{0S1Sn6FwC*AH6(C;P9-vV=kH29T%tO|gynov^+01N8rEK8hZl&RaSOw|5Yx^B~xVgGjp0Vik$ZvREEDKCyIi z4BrE<9KTp4IaRSvPAT_UEI7DW?jr~Wr?Ssr@K?_u+++W*oUpy~n_wI9OJhs&(=Bn$ YwET!|foWzXkJP2c?>OE^NiF%~1r*=HlmGw# literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/whatsapp.png b/app/src/main/res/drawable-xxhdpi/whatsapp.png new file mode 100644 index 0000000000000000000000000000000000000000..ca10482946dc1276cbe10f3bcc134d4ca9814a87 GIT binary patch literal 993 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY0wn)GsXhawSkfJR9T^xl_H+M9WCik>lDyqr z82-2SpV<%Ov6p!Iy0SmzM2^3ASs37cj4LX{Y^6amd0iicGF1*xmVFDj( z$gL8U%O+cA-g6L|Ut70r{`vR2e0QZ!*xmj2&(FViKHp{Ba>+~ke`il&Q@vzj%Y$55 z89Tjujtt9kruQ=)b=r8_H8>$Y>wwPf{YkSo@v1CbHqZ0VXa5cEG4DDZEmy@S37Vc_ zE&S-klzBQ(y4|qJ-E7`5&b&|C>yGY9Iwy7gkmB1A|2Mbe!zLZv$S}#UMql{&*30cF zD#;(#aqu0|J?y5%5F+=hTD5DFA;;eAF9*IKJ1|p%k3lQDkTdUco2_usboIwUg?H|} zN=uKuZZUJy1SX3zOM#6+asnKTZ(ki2WcDn#%i(!pchb%0w}<6hgC3I)e&+cc8fnUR zbK?RXa}$0WJ-w#?v@H4l%q*X*b=)Bz7OhxqeOD;hbJv;azpidt@PEmx=|$Hj1wZA< zZJV}LMD*&*&t30$D@{31OpD{=W!RIbt1cwFJV~&{p!tmi(}u?@nC~0S`NrTMw(Hfg zfY5!+a|u5w>E?{mqIrAkB>r4E@0WWd&%xgp)ZQz)ie6+2I%;`o*80c7 zGj_k)y@cu2@5gq^bv>xm40zJQ_on|o;^Uq9{MboL z#wB|ktWtii?l>J@BRX^L8Ka5YPqx@JbQ&xDzVJH#h@E}H;g}Wf#)*n)Pt;GP-&wKx zdu~|Y$2|6b>-!I0O5B}OxY@t-QGUdFKe;OcI6;=xw_=%lwH<^6iq3eiS#Y|D1bfPWSbdGiNJVi$2OC z7#SED>Ka(;8XAWf8dw<`S{ayV8yHv_80bu$coaoLZhlH;S|x4`u_rVuff^V*UHx3v IIVCg!0JRvhH2?qr literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxxhdpi/ic_content_copy_white_24dp.webp b/app/src/main/res/drawable-xxxhdpi/ic_content_copy_white_24dp.webp new file mode 100644 index 0000000000000000000000000000000000000000..81ae8168ec57ccd5e7d248f7defc279c6f02d595 GIT binary patch literal 206 zcmV;<05ShkNk&G-00012MM6+kP&iDv0000lU%(d-zcByvKmUr_MslN@TqAJre#kBm zZvfyyhz|e+$T$$90|+nx3V;Jil1yu!vF6f_5DeP0&?f>CVCcF zOmbZlSz08lBh~7ZSg^x)g2$1lJEypXc6LqfNH?@#Nt*2aPJV{)ld_l9e+`Z2etY}$ zUii+pXTJMdm)yF}nsH%)V>8=-2P8H%O6KI6KDH16~FA*7_|LG^VO@Cmu_lKZ&6w(ecPsBdJ2b_cu$BsrP2ZDo*tbtne{$?pJ;PW>RXr(S~Bl9WNKxnDoTB9ec2{bjMPa zYpwGbl6nf3YCVkGr!C*JPHgq|I2O^nuYDCc9_GBx1XEH}+&h9nKu z<~!G;c6HA2IdD!BD0Sws+RIO;ZT|92yrg|yDJwW<_Us>=nr*_D_}8>gD|J}WzE(c9 zC8Xi_FY%psBjtYS*~cBd!aA|;r^C`UKAH+QkFJSnn14y>G>;1>N2u9c8zGx-%$|RA z6Mn9pv}x`c`EDkrx{bX1o<7Y#dbZ`O=EpO;r*7o@=&s?dVW7mGVR2h!n%Eq#XfdFQuwiN@opB2`vO~gMK?f%{)Q6c;REe8&3U z<7eE7PA7+Y*&j!h)c(ZtEM|#tQs}CWQFyHS!6hOguYXUBj^%W_3*38kyu|;X`l|Fi zX{xR8mcWa7iyu@?dug>r&-*V+yjJ)sCI$vG(OGOZzooyG7+Pa*ZeL(Ej`iXISnXb*cWbt+4nKy3}lY5$pFDm4#Ucr|hN7?) z7EeN#Yc1QG>s7M%pR7&G2Z+0lQMTlBn^uKfF-{|}~{y~4A-R&2~yF24RxmvZvHKHUXu_VKYn{7#dg^7+M(_Xd3_-2GNDN*(e%v^HVa@DsgMj(z$vasDZ)L)z4*} HQ$iB}Td54( literal 0 HcmV?d00001 diff --git a/app/src/main/res/layout/activity_place_picker.xml b/app/src/main/res/layout/activity_place_picker.xml index e1ff0d29ce2..53a28494f4c 100644 --- a/app/src/main/res/layout/activity_place_picker.xml +++ b/app/src/main/res/layout/activity_place_picker.xml @@ -29,6 +29,38 @@ app:layout_constraintVertical_bias="0.0" app:backgroundColor="@color/signal_colorBackground"/> + + + +