(NUM_TRUSTS_TO_CACHE) {
+ @Override
+ protected FingerprintStatus create(String fingerprint) {
+ return mXmppConnectionService.databaseBackend.getFingerprintStatus(
+ account, fingerprint);
+ }
+ };
+
+ private static IdentityKeyPair generateIdentityKeyPair() {
+ Log.i(
+ Config.LOGTAG,
+ AxolotlService.LOGPREFIX + " : " + "Generating axolotl IdentityKeyPair...");
+ ECKeyPair identityKeyPairKeys = Curve.generateKeyPair();
+ return new IdentityKeyPair(
+ new IdentityKey(identityKeyPairKeys.getPublicKey()),
+ identityKeyPairKeys.getPrivateKey());
+ }
+
+ private static int generateRegistrationId() {
+ Log.i(
+ Config.LOGTAG,
+ AxolotlService.LOGPREFIX + " : " + "Generating axolotl registration ID...");
+ return KeyHelper.generateRegistrationId(true);
+ }
+
+ public SQLiteAxolotlStore(Account account, XmppConnectionService service) {
+ this.account = account;
+ this.mXmppConnectionService = service;
+ this.localRegistrationId = loadRegistrationId();
+ this.currentPreKeyId = loadCurrentPreKeyId();
+ }
+
+ public int getCurrentPreKeyId() {
+ return currentPreKeyId;
+ }
+
+ // --------------------------------------
+ // IdentityKeyStore
+ // --------------------------------------
+
+ private IdentityKeyPair loadIdentityKeyPair() {
+ synchronized (mXmppConnectionService) {
+ IdentityKeyPair ownKey =
+ mXmppConnectionService.databaseBackend.loadOwnIdentityKeyPair(account);
+
+ if (ownKey != null) {
+ return ownKey;
+ } else {
+ Log.i(
+ Config.LOGTAG,
+ AxolotlService.getLogprefix(account)
+ + "Could not retrieve own IdentityKeyPair");
+ ownKey = generateIdentityKeyPair();
+ mXmppConnectionService.databaseBackend.storeOwnIdentityKeyPair(account, ownKey);
+ }
+ return ownKey;
+ }
+ }
+
+ private int loadRegistrationId() {
+ return loadRegistrationId(false);
+ }
+
+ private int loadRegistrationId(boolean regenerate) {
+ String regIdString = this.account.getKey(JSONKEY_REGISTRATION_ID);
+ int reg_id;
+ if (!regenerate && regIdString != null) {
+ reg_id = Integer.valueOf(regIdString);
+ } else {
+ Log.i(
+ Config.LOGTAG,
+ AxolotlService.getLogprefix(account)
+ + "Could not retrieve axolotl registration id for account "
+ + account.getJid());
+ reg_id = generateRegistrationId();
+ boolean success =
+ this.account.setKey(JSONKEY_REGISTRATION_ID, Integer.toString(reg_id));
+ if (success) {
+ mXmppConnectionService.databaseBackend.updateAccount(account);
+ } else {
+ Log.e(
+ Config.LOGTAG,
+ AxolotlService.getLogprefix(account)
+ + "Failed to write new key to the database!");
+ }
+ }
+ return reg_id;
+ }
+
+ private int loadCurrentPreKeyId() {
+ String prekeyIdString = this.account.getKey(JSONKEY_CURRENT_PREKEY_ID);
+ int prekey_id;
+ if (prekeyIdString != null) {
+ prekey_id = Integer.valueOf(prekeyIdString);
+ } else {
+ Log.w(
+ Config.LOGTAG,
+ AxolotlService.getLogprefix(account)
+ + "Could not retrieve current prekey id for account "
+ + account.getJid());
+ prekey_id = 0;
+ }
+ return prekey_id;
+ }
+
+ public void regenerate() {
+ mXmppConnectionService.databaseBackend.wipeAxolotlDb(account);
+ trustCache.evictAll();
+ account.setKey(JSONKEY_CURRENT_PREKEY_ID, Integer.toString(0));
+ identityKeyPair = loadIdentityKeyPair();
+ localRegistrationId = loadRegistrationId(true);
+ currentPreKeyId = 0;
+ mXmppConnectionService.updateAccountUi();
+ }
+
+ /**
+ * Get the local client's identity key pair.
+ *
+ * @return The local client's persistent identity key pair.
+ */
+ @Override
+ public IdentityKeyPair getIdentityKeyPair() {
+ if (identityKeyPair == null) {
+ identityKeyPair = loadIdentityKeyPair();
+ }
+ return identityKeyPair;
+ }
+
+ /**
+ * Return the local client's registration ID.
+ *
+ * Clients should maintain a registration ID, a random number between 1 and 16380 that's
+ * generated once at install time.
+ *
+ * @return the local client's registration ID.
+ */
+ @Override
+ public int getLocalRegistrationId() {
+ return localRegistrationId;
+ }
+
+ /**
+ * Save a remote client's identity key
+ *
+ *
Store a remote client's identity key as trusted.
+ *
+ * @param address The address of the remote client.
+ * @param identityKey The remote client's identity key.
+ * @return true on success
+ */
+ @Override
+ public boolean saveIdentity(
+ final SignalProtocolAddress address, final IdentityKey identityKey) {
+ if (!mXmppConnectionService
+ .databaseBackend
+ .loadIdentityKeys(account, address.getName())
+ .contains(identityKey)) {
+ String fingerprint = CryptoHelper.bytesToHex(identityKey.getPublicKey().serialize());
+ FingerprintStatus status = getFingerprintStatus(fingerprint);
+ if (status == null) {
+ if (mXmppConnectionService.getAppSettings().isBTBVEnabled()
+ && !account.getAxolotlService().hasVerifiedKeys(address.getName())) {
+ Log.d(
+ Config.LOGTAG,
+ account.getJid().asBareJid()
+ + ": blindly trusted "
+ + fingerprint
+ + " of "
+ + address.getName());
+ status = FingerprintStatus.createActiveTrusted();
+ } else {
+ status = FingerprintStatus.createActiveUndecided();
+ }
+ } else {
+ status = status.toActive();
+ }
+ mXmppConnectionService.databaseBackend.storeIdentityKey(
+ account, address.getName(), identityKey, status);
+ trustCache.remove(fingerprint);
+ }
+ return true;
+ }
+
+ /**
+ * Verify a remote client's identity key.
+ *
+ *
Determine whether a remote client's identity is trusted. Convention is that the TextSecure
+ * protocol is 'trust on first use.' This means that an identity key is considered 'trusted' if
+ * there is no entry for the recipient in the local store, or if it matches the saved key for a
+ * recipient in the local store. Only if it mismatches an entry in the local store is it
+ * considered 'untrusted.'
+ *
+ * @param identityKey The identity key to verify.
+ * @return true if trusted, false if untrusted.
+ */
+ @Override
+ public boolean isTrustedIdentity(
+ SignalProtocolAddress address, IdentityKey identityKey, Direction direction) {
+ return true;
+ }
+
+ public FingerprintStatus getFingerprintStatus(String fingerprint) {
+ return (fingerprint == null) ? null : trustCache.get(fingerprint);
+ }
+
+ public void setFingerprintStatus(String fingerprint, FingerprintStatus status) {
+ mXmppConnectionService.databaseBackend.setIdentityKeyTrust(account, fingerprint, status);
+ trustCache.remove(fingerprint);
+ }
+
+ public void setFingerprintCertificate(String fingerprint, X509Certificate x509Certificate) {
+ mXmppConnectionService.databaseBackend.setIdentityKeyCertificate(
+ account, fingerprint, x509Certificate);
+ }
+
+ public X509Certificate getFingerprintCertificate(String fingerprint) {
+ return mXmppConnectionService.databaseBackend.getIdentityKeyCertifcate(
+ account, fingerprint);
+ }
+
+ public Set getContactKeysWithTrust(String bareJid, FingerprintStatus status) {
+ return mXmppConnectionService.databaseBackend.loadIdentityKeys(account, bareJid, status);
+ }
+
+ public long getContactNumTrustedKeys(String bareJid) {
+ return mXmppConnectionService.databaseBackend.numTrustedKeys(account, bareJid);
+ }
+
+ // --------------------------------------
+ // SessionStore
+ // --------------------------------------
+
+ /**
+ * Returns a copy of the {@link SessionRecord} corresponding to the recipientId + deviceId
+ * tuple, or a new SessionRecord if one does not currently exist.
+ *
+ * It is important that implementations return a copy of the current durable information. The
+ * returned SessionRecord may be modified, but those changes should not have an effect on the
+ * durable session state (what is returned by subsequent calls to this method) without the store
+ * method being called here first.
+ *
+ * @param address The name and device ID of the remote client.
+ * @return a copy of the SessionRecord corresponding to the recipientId + deviceId tuple, or a
+ * new SessionRecord if one does not currently exist.
+ */
+ @Override
+ public SessionRecord loadSession(SignalProtocolAddress address) {
+ SessionRecord session =
+ mXmppConnectionService.databaseBackend.loadSession(this.account, address);
+ return (session != null) ? session : new SessionRecord();
+ }
+
+ /**
+ * Returns all known devices with active sessions for a recipient
+ *
+ * @param name the name of the client.
+ * @return all known sub-devices with active sessions.
+ */
+ @Override
+ public List getSubDeviceSessions(String name) {
+ return mXmppConnectionService.databaseBackend.getSubDeviceSessions(
+ account, new SignalProtocolAddress(name, 0));
+ }
+
+ public List getKnownAddresses() {
+ return mXmppConnectionService.databaseBackend.getKnownSignalAddresses(account);
+ }
+
+ /**
+ * Commit to storage the {@link SessionRecord} for a given recipientId + deviceId tuple.
+ *
+ * @param address the address of the remote client.
+ * @param record the current SessionRecord for the remote client.
+ */
+ @Override
+ public void storeSession(SignalProtocolAddress address, SessionRecord record) {
+ mXmppConnectionService.databaseBackend.storeSession(account, address, record);
+ }
+
+ /**
+ * Determine whether there is a committed {@link SessionRecord} for a recipientId + deviceId
+ * tuple.
+ *
+ * @param address the address of the remote client.
+ * @return true if a {@link SessionRecord} exists, false otherwise.
+ */
+ @Override
+ public boolean containsSession(SignalProtocolAddress address) {
+ return mXmppConnectionService.databaseBackend.containsSession(account, address);
+ }
+
+ /**
+ * Remove a {@link SessionRecord} for a recipientId + deviceId tuple.
+ *
+ * @param address the address of the remote client.
+ */
+ @Override
+ public void deleteSession(SignalProtocolAddress address) {
+ mXmppConnectionService.databaseBackend.deleteSession(account, address);
+ }
+
+ /**
+ * Remove the {@link SessionRecord}s corresponding to all devices of a recipientId.
+ *
+ * @param name the name of the remote client.
+ */
+ @Override
+ public void deleteAllSessions(String name) {
+ SignalProtocolAddress address = new SignalProtocolAddress(name, 0);
+ mXmppConnectionService.databaseBackend.deleteAllSessions(account, address);
+ }
+
+ // --------------------------------------
+ // PreKeyStore
+ // --------------------------------------
+
+ /**
+ * Load a local PreKeyRecord.
+ *
+ * @param preKeyId the ID of the local PreKeyRecord.
+ * @return the corresponding PreKeyRecord.
+ * @throws InvalidKeyIdException when there is no corresponding PreKeyRecord.
+ */
+ @Override
+ public PreKeyRecord loadPreKey(int preKeyId) throws InvalidKeyIdException {
+ PreKeyRecord record = mXmppConnectionService.databaseBackend.loadPreKey(account, preKeyId);
+ if (record == null) {
+ throw new InvalidKeyIdException("No such PreKeyRecord: " + preKeyId);
+ }
+ return record;
+ }
+
+ /**
+ * Store a local PreKeyRecord.
+ *
+ * @param preKeyId the ID of the PreKeyRecord to store.
+ * @param record the PreKeyRecord.
+ */
+ @Override
+ public void storePreKey(int preKeyId, PreKeyRecord record) {
+ mXmppConnectionService.databaseBackend.storePreKey(account, record);
+ currentPreKeyId = preKeyId;
+ boolean success =
+ this.account.setKey(JSONKEY_CURRENT_PREKEY_ID, Integer.toString(preKeyId));
+ if (success) {
+ mXmppConnectionService.databaseBackend.updateAccount(account);
+ } else {
+ Log.e(
+ Config.LOGTAG,
+ AxolotlService.getLogprefix(account)
+ + "Failed to write new prekey id to the database!");
+ }
+ }
+
+ /**
+ * @param preKeyId A PreKeyRecord ID.
+ * @return true if the store has a record for the preKeyId, otherwise false.
+ */
+ @Override
+ public boolean containsPreKey(int preKeyId) {
+ return mXmppConnectionService.databaseBackend.containsPreKey(account, preKeyId);
+ }
+
+ /**
+ * Delete a PreKeyRecord from local storage.
+ *
+ * @param preKeyId The ID of the PreKeyRecord to remove.
+ */
+ @Override
+ public void removePreKey(int preKeyId) {
+ Log.d(Config.LOGTAG, "mark prekey for removal " + preKeyId);
+ synchronized (preKeysMarkedForRemoval) {
+ preKeysMarkedForRemoval.add(preKeyId);
+ }
+ }
+
+ public boolean flushPreKeys() {
+ Log.d(Config.LOGTAG, "flushing pre keys");
+ int count = 0;
+ synchronized (preKeysMarkedForRemoval) {
+ for (Integer preKeyId : preKeysMarkedForRemoval) {
+ count += mXmppConnectionService.databaseBackend.deletePreKey(account, preKeyId);
+ }
+ preKeysMarkedForRemoval.clear();
+ }
+ return count > 0;
+ }
+
+ // --------------------------------------
+ // SignedPreKeyStore
+ // --------------------------------------
+
+ /**
+ * Load a local SignedPreKeyRecord.
+ *
+ * @param signedPreKeyId the ID of the local SignedPreKeyRecord.
+ * @return the corresponding SignedPreKeyRecord.
+ * @throws InvalidKeyIdException when there is no corresponding SignedPreKeyRecord.
+ */
+ @Override
+ public SignedPreKeyRecord loadSignedPreKey(int signedPreKeyId) throws InvalidKeyIdException {
+ SignedPreKeyRecord record =
+ mXmppConnectionService.databaseBackend.loadSignedPreKey(account, signedPreKeyId);
+ if (record == null) {
+ throw new InvalidKeyIdException("No such SignedPreKeyRecord: " + signedPreKeyId);
+ }
+ return record;
+ }
+
+ /**
+ * Load all local SignedPreKeyRecords.
+ *
+ * @return All stored SignedPreKeyRecords.
+ */
+ @Override
+ public List loadSignedPreKeys() {
+ return mXmppConnectionService.databaseBackend.loadSignedPreKeys(account);
+ }
+
+ public int getSignedPreKeysCount() {
+ return mXmppConnectionService.databaseBackend.getSignedPreKeysCount(account);
+ }
+
+ /**
+ * Store a local SignedPreKeyRecord.
+ *
+ * @param signedPreKeyId the ID of the SignedPreKeyRecord to store.
+ * @param record the SignedPreKeyRecord.
+ */
+ @Override
+ public void storeSignedPreKey(int signedPreKeyId, SignedPreKeyRecord record) {
+ mXmppConnectionService.databaseBackend.storeSignedPreKey(account, record);
+ }
+
+ /**
+ * @param signedPreKeyId A SignedPreKeyRecord ID.
+ * @return true if the store has a record for the signedPreKeyId, otherwise false.
+ */
+ @Override
+ public boolean containsSignedPreKey(int signedPreKeyId) {
+ return mXmppConnectionService.databaseBackend.containsSignedPreKey(account, signedPreKeyId);
+ }
+
+ /**
+ * Delete a SignedPreKeyRecord from local storage.
+ *
+ * @param signedPreKeyId The ID of the SignedPreKeyRecord to remove.
+ */
+ @Override
+ public void removeSignedPreKey(int signedPreKeyId) {
+ mXmppConnectionService.databaseBackend.deleteSignedPreKey(account, signedPreKeyId);
+ }
+
+ public void preVerifyFingerprint(Account account, String name, String fingerprint) {
+ mXmppConnectionService.databaseBackend.storePreVerification(
+ account, name, fingerprint, FingerprintStatus.createInactiveVerified());
+ }
}
diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
index ce482490e10f26ed49e3ed29aea70d472cb4ef99..32b1ae4645475c275d3e39abf1156635168cb78e 100644
--- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
+++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
@@ -6341,10 +6341,6 @@ public class XmppConnectionService extends Service {
return verifiedSomething;
}
- public boolean blindTrustBeforeVerification() {
- return getBooleanPreference(AppSettings.BLIND_TRUST_BEFORE_VERIFICATION, R.bool.btbv);
- }
-
public ShortcutService getShortcutService() {
return mShortcutService;
}
diff --git a/src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java b/src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java
index 739598a69009561a7f6a6bec4076fc6530d31c32..2a92bebf594fee436d42cedeb515ee250abcfe25 100644
--- a/src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java
+++ b/src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java
@@ -1,12 +1,13 @@
package eu.siacs.conversations.ui.fragment.settings;
import android.Manifest;
+import android.content.Intent;
import android.content.pm.PackageManager;
+import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.widget.Toast;
-
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
@@ -22,16 +23,13 @@ import androidx.work.OneTimeWorkRequest;
import androidx.work.OutOfQuotaPolicy;
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;
-
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.common.base.Strings;
import com.google.common.primitives.Longs;
-
+import eu.siacs.conversations.AppSettings;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
-import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.worker.ExportBackupWorker;
-
import java.util.concurrent.TimeUnit;
public class BackupSettingsFragment extends XmppPreferenceFragment {
@@ -56,20 +54,33 @@ public class BackupSettingsFragment extends XmppPreferenceFragment {
}
});
+ private final ActivityResultLauncher pickBackupLocationLauncher =
+ registerForActivityResult(
+ new ActivityResultContracts.OpenDocumentTree(),
+ uri -> {
+ if (uri == null) {
+ Log.d(Config.LOGTAG, "no backup location selected");
+ return;
+ }
+ submitBackupLocationPreference(uri);
+ });
+
@Override
public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) {
setPreferencesFromResource(R.xml.preferences_backup, rootKey);
final var createOneOffBackup = findPreference(CREATE_ONE_OFF_BACKUP);
final ListPreference recurringBackup = findPreference(RECURRING_BACKUP);
- final var backupDirectory = findPreference("backup_directory");
- if (createOneOffBackup == null || recurringBackup == null || backupDirectory == null) {
+ final var backupLocation = findPreference(AppSettings.BACKUP_LOCATION);
+ if (createOneOffBackup == null || recurringBackup == null || backupLocation == null) {
throw new IllegalStateException(
"The preference resource file is missing some preferences");
}
- backupDirectory.setSummary(
+ final var appSettings = new AppSettings(requireContext());
+ backupLocation.setSummary(
getString(
R.string.pref_create_backup_summary,
- FileBackend.getBackupDirectory(requireContext()).getAbsolutePath()));
+ appSettings.getBackupLocationAsPath()));
+ backupLocation.setOnPreferenceClickListener(this::onBackupLocationPreferenceClicked);
createOneOffBackup.setOnPreferenceClickListener(this::onBackupPreferenceClicked);
setValues(
recurringBackup,
@@ -77,6 +88,26 @@ public class BackupSettingsFragment extends XmppPreferenceFragment {
value -> timeframeValueToName(requireContext(), value));
}
+ private boolean onBackupLocationPreferenceClicked(final Preference preference) {
+ this.pickBackupLocationLauncher.launch(null);
+ return false;
+ }
+
+ private void submitBackupLocationPreference(final Uri uri) {
+ final var contentResolver = requireContext().getContentResolver();
+ contentResolver.takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ final var appSettings = new AppSettings(requireContext());
+ appSettings.setBackupLocation(uri);
+ final var preference = findPreference(AppSettings.BACKUP_LOCATION);
+ if (preference == null) {
+ return;
+ }
+ preference.setSummary(
+ getString(R.string.pref_create_backup_summary, AppSettings.asPath(uri)));
+ }
+
@Override
protected void onSharedPreferenceChanged(@NonNull String key) {
super.onSharedPreferenceChanged(key);
diff --git a/src/main/java/eu/siacs/conversations/utils/BackupFile.java b/src/main/java/eu/siacs/conversations/utils/BackupFile.java
index 01ecf35b83d4d0d6973a54da84c3374d2a89289c..7644f6ccbf2f64f08140159e53ae9f1edd1ad34c 100644
--- a/src/main/java/eu/siacs/conversations/utils/BackupFile.java
+++ b/src/main/java/eu/siacs/conversations/utils/BackupFile.java
@@ -1,8 +1,12 @@
package eu.siacs.conversations.utils;
import android.content.Context;
+import android.content.UriPermission;
import android.net.Uri;
+import android.os.Build;
+import android.provider.DocumentsContract;
import android.util.Log;
+import androidx.documentfile.provider.DocumentFile;
import com.google.common.collect.Collections2;
import com.google.common.collect.ComparisonChain;
import com.google.common.collect.ImmutableList;
@@ -15,6 +19,7 @@ import eu.siacs.conversations.R;
import eu.siacs.conversations.persistance.DatabaseBackend;
import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.services.QuickConversationsService;
+import eu.siacs.conversations.worker.ExportBackupWorker;
import eu.siacs.conversations.xmpp.Jid;
import java.io.DataInputStream;
import java.io.File;
@@ -81,11 +86,51 @@ public class BackupFile implements Comparable {
final var backupFiles = new ImmutableList.Builder();
final var apps =
ImmutableSet.of("Conversations", "Quicksy", context.getString(R.string.app_name));
+
+ final var uriPermissions = context.getContentResolver().getPersistedUriPermissions();
+
+ for (final UriPermission uriPermission : uriPermissions) {
+ final var uri = uriPermission.getUri();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
+ && DocumentsContract.isTreeUri(uri)) {
+ Log.d(Config.LOGTAG, "looking for backups in " + uri);
+ final var tree = DocumentFile.fromTreeUri(context, uriPermission.getUri());
+ final var files = tree == null ? new DocumentFile[0] : tree.listFiles();
+ for (final DocumentFile documentFile : files) {
+ final var name = documentFile.getName();
+ if (documentFile.isFile()
+ && (ExportBackupWorker.MIME_TYPE.equals(documentFile.getType())
+ || (name != null && name.endsWith(".ceb")))) {
+ try {
+ final BackupFile backupFile =
+ BackupFile.read(context, documentFile.getUri());
+ if (accounts.contains(backupFile.getHeader().getJid())) {
+ Log.d(
+ Config.LOGTAG,
+ "skipping backup for " + backupFile.getHeader().getJid());
+ } else {
+ backupFiles.add(backupFile);
+ }
+ } catch (final IOException
+ | IllegalArgumentException
+ | BackupFileHeader.OutdatedBackupFileVersion e) {
+ Log.d(Config.LOGTAG, "unable to read backup file ", e);
+ }
+ }
+ }
+ }
+ }
+
final List directories = new ArrayList<>();
for (final String app : apps) {
directories.add(FileBackend.getLegacyBackupDirectory(app));
}
- directories.add(FileBackend.getBackupDirectory(context));
+ if (uriPermissions.isEmpty()) {
+ Log.d(
+ Config.LOGTAG,
+ "including default directory since no uri permissions have been granted");
+ directories.add(FileBackend.getBackupDirectory(context));
+ }
for (final File directory : directories) {
if (!directory.exists() || !directory.isDirectory()) {
Log.d(Config.LOGTAG, "directory not found: " + directory.getAbsolutePath());
@@ -134,7 +179,7 @@ public class BackupFile implements Comparable {
public int compareTo(final BackupFile o) {
return ComparisonChain.start()
.compare(header.getJid(), o.header.getJid())
- .compare(header.getTimestamp(), o.header.getTimestamp())
+ .compare(o.header.getTimestamp(), header.getTimestamp())
.result();
}
}
diff --git a/src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java b/src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java
index c5178e0adfa91c251b8afa028711964fd1ba5891..2ec4fc45e1b60038933bdbdc6100f076e260067a 100644
--- a/src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java
+++ b/src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java
@@ -15,14 +15,15 @@ import android.os.SystemClock;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
+import androidx.documentfile.provider.DocumentFile;
import androidx.work.ForegroundInfo;
import androidx.work.WorkManager;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
-import com.google.common.base.Optional;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.gson.stream.JsonWriter;
+import eu.siacs.conversations.AppSettings;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
@@ -38,6 +39,7 @@ import java.io.DataOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
+import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
@@ -92,7 +94,7 @@ public class ExportBackupWorker extends Worker {
@Override
public Result doWork() {
setForegroundAsync(getForegroundInfo());
- final List files;
+ final List files;
try {
files = export();
} catch (final IOException
@@ -132,7 +134,7 @@ public class ExportBackupWorker extends Worker {
}
}
- private List export()
+ private List export()
throws IOException,
InvalidKeySpecException,
InvalidAlgorithmParameterException,
@@ -141,17 +143,19 @@ public class ExportBackupWorker extends Worker {
NoSuchAlgorithmException,
NoSuchProviderException {
final Context context = getApplicationContext();
+ final var appSettings = new AppSettings(context);
+ final var backupLocation = appSettings.getBackupLocation();
final var database = DatabaseBackend.getInstance(context);
final var accounts = database.getAccounts();
int count = 0;
final int max = accounts.size();
- final ImmutableList.Builder files = new ImmutableList.Builder<>();
+ final ImmutableList.Builder locations = new ImmutableList.Builder<>();
Log.d(Config.LOGTAG, "starting backup for " + max + " accounts");
for (final Account account : accounts) {
if (isStopped()) {
Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
- return files.build();
+ return locations.build();
}
final String password = account.getPassword();
if (Strings.nullToEmpty(password).trim().isEmpty()) {
@@ -164,34 +168,24 @@ public class ExportBackupWorker extends Worker {
count++;
continue;
}
- final String filename =
- String.format(
- "%s.%s.ceb",
- account.getJid().asBareJid().toString(),
- DATE_FORMAT.format(new Date()));
- final File file = new File(FileBackend.getBackupDirectory(context), filename);
+ final Uri uri;
try {
- export(database, account, password, file, max, count);
+ uri = export(database, account, password, backupLocation, max, count);
} catch (final WorkStoppedException e) {
- if (file.delete()) {
- Log.d(
- Config.LOGTAG,
- "deleted in progress backup file " + file.getAbsolutePath());
- }
Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
- return files.build();
+ return locations.build();
}
- files.add(file);
+ locations.add(uri);
count++;
}
- return files.build();
+ return locations.build();
}
- private void export(
+ private Uri export(
final DatabaseBackend database,
final Account account,
final String password,
- final File file,
+ final Uri backupLocation,
final int max,
final int count)
throws IOException,
@@ -230,12 +224,36 @@ public class ExportBackupWorker extends Worker {
cancelPendingIntent)
.build());
final Progress progress = new Progress(notification, max, count);
- final File directory = file.getParentFile();
- if (directory != null && directory.mkdirs()) {
- Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
+ final String filename =
+ String.format(
+ "%s.%s.ceb",
+ account.getJid().asBareJid().toString(), DATE_FORMAT.format(new Date()));
+ final OutputStream outputStream;
+ final Uri location;
+ if ("file".equalsIgnoreCase(backupLocation.getScheme())) {
+ final File file = new File(backupLocation.getPath(), filename);
+ final File directory = file.getParentFile();
+ if (directory != null && directory.mkdirs()) {
+ Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
+ }
+ outputStream = new FileOutputStream(file);
+ location = Uri.fromFile(file);
+ } else {
+ final var tree = DocumentFile.fromTreeUri(context, backupLocation);
+ if (tree == null) {
+ throw new IOException(
+ String.format(
+ "DocumentFile.fromTreeUri returned null for %s", backupLocation));
+ }
+ final var file = tree.createFile(MIME_TYPE, filename);
+ if (file == null) {
+ throw new IOException(
+ String.format("Could not create %s in %s", filename, backupLocation));
+ }
+ location = file.getUri();
+ outputStream = context.getContentResolver().openOutputStream(location);
}
- final FileOutputStream fileOutputStream = new FileOutputStream(file);
- final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
+ final DataOutputStream dataOutputStream = new DataOutputStream(outputStream);
backupFileHeader.write(dataOutputStream);
dataOutputStream.flush();
@@ -247,7 +265,7 @@ public class ExportBackupWorker extends Worker {
SecretKeySpec keySpec = new SecretKeySpec(key, KEY_TYPE);
IvParameterSpec ivSpec = new IvParameterSpec(IV);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
- CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher);
+ CipherOutputStream cipherOutputStream = new CipherOutputStream(outputStream, cipher);
final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
final JsonWriter jsonWriter =
@@ -257,21 +275,24 @@ public class ExportBackupWorker extends Worker {
final String uuid = account.getUuid();
accountExport(db, uuid, jsonWriter);
simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, jsonWriter);
- messageExport(db, uuid, jsonWriter, progress);
+ messageExport(db, uuid, location, jsonWriter, progress);
for (final String table :
Arrays.asList(
SQLiteAxolotlStore.PREKEY_TABLENAME,
SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
SQLiteAxolotlStore.SESSION_TABLENAME,
SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
- throwIfWorkStopped();
+ throwIfWorkStopped(location);
simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, jsonWriter);
}
jsonWriter.endArray();
jsonWriter.flush();
jsonWriter.close();
- mediaScannerScanFile(file);
- Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
+ if ("file".equalsIgnoreCase(location.getScheme())) {
+ mediaScannerScanFile(new File(location.getPath()));
+ }
+ Log.d(Config.LOGTAG, "written backup to " + location);
+ return location;
}
private NotificationCompat.Builder getNotification() {
@@ -287,8 +308,20 @@ public class ExportBackupWorker extends Worker {
return notification;
}
- private void throwIfWorkStopped() throws WorkStoppedException {
+ private void throwIfWorkStopped(final Uri location) throws WorkStoppedException {
if (isStopped()) {
+ if ("file".equalsIgnoreCase(location.getScheme())) {
+ final var file = new File(location.getPath());
+ if (file.delete()) {
+ Log.d(Config.LOGTAG, "deleted " + file.getAbsolutePath());
+ }
+ } else {
+ final var documentFile =
+ DocumentFile.fromSingleUri(getApplicationContext(), location);
+ if (documentFile != null && documentFile.delete()) {
+ Log.d(Config.LOGTAG, "deleted " + location);
+ }
+ }
throw new WorkStoppedException();
}
}
@@ -371,6 +404,7 @@ public class ExportBackupWorker extends Worker {
private void messageExport(
final SQLiteDatabase db,
final String uuid,
+ final Uri location,
final JsonWriter writer,
final Progress progress)
throws IOException, WorkStoppedException {
@@ -388,7 +422,7 @@ public class ExportBackupWorker extends Worker {
int i = 0;
int p = Integer.MIN_VALUE;
while (cursor != null && cursor.moveToNext()) {
- throwIfWorkStopped();
+ throwIfWorkStopped(location);
writer.beginObject();
writer.name("table");
writer.value(Message.TABLENAME);
@@ -425,16 +459,18 @@ public class ExportBackupWorker extends Worker {
.getEncoded();
}
- private void notifySuccess(final List files) {
+ private void notifySuccess(final List locations) {
final var context = getApplicationContext();
- final String path = FileBackend.getBackupDirectory(context).getAbsolutePath();
-
- final var openFolderIntent = getOpenFolderIntent(path);
-
+ final var appSettings = new AppSettings(context);
+ final String path = appSettings.getBackupLocationAsPath();
final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
final ArrayList uris = new ArrayList<>();
- for (final File file : files) {
- uris.add(FileBackend.getUriForFile(context, file));
+ for (final Uri uri : locations) {
+ if ("file".equalsIgnoreCase(uri.getScheme())) {
+ uris.add(FileBackend.getUriForFile(context, new File(uri.getPath())));
+ } else {
+ uris.add(uri);
+ }
}
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
@@ -444,8 +480,8 @@ public class ExportBackupWorker extends Worker {
final var shareFilesIntent =
PendingIntent.getActivity(context, 190, chooser, PENDING_INTENT_FLAGS);
- NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, "backup");
- mBuilder.setContentTitle(context.getString(R.string.notification_backup_created_title))
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, "backup");
+ builder.setContentTitle(context.getString(R.string.notification_backup_created_title))
.setContentText(
context.getString(R.string.notification_backup_created_subtitle, path))
.setStyle(
@@ -453,60 +489,17 @@ public class ExportBackupWorker extends Worker {
.bigText(
context.getString(
R.string.notification_backup_created_subtitle,
- FileBackend.getBackupDirectory(context)
- .getAbsolutePath())))
+ path)))
.setAutoCancel(true)
.setSmallIcon(R.drawable.ic_archive_24dp);
- if (openFolderIntent.isPresent()) {
- mBuilder.setContentIntent(openFolderIntent.get());
- } else {
- Log.w(Config.LOGTAG, "no app can display folders");
- }
-
- mBuilder.addAction(
+ builder.addAction(
R.drawable.ic_share_24dp,
context.getString(R.string.share_backup_files),
shareFilesIntent);
+ builder.setLocalOnly(true);
final var notificationManager = context.getSystemService(NotificationManager.class);
- notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, mBuilder.build());
- }
-
- private Optional getOpenFolderIntent(final String path) {
- final var context = getApplicationContext();
- for (final Intent intent : getPossibleFileOpenIntents(context, path)) {
- if (intent.resolveActivityInfo(context.getPackageManager(), 0) != null) {
- return Optional.of(
- PendingIntent.getActivity(context, 189, intent, PENDING_INTENT_FLAGS));
- }
- }
- return Optional.absent();
- }
-
- private static List getPossibleFileOpenIntents(
- final Context context, final String path) {
-
- // http://www.openintents.org/action/android-intent-action-view/file-directory
- // do not use 'vnd.android.document/directory' since this will trigger system file manager
- final Intent openIntent = new Intent(Intent.ACTION_VIEW);
- openIntent.addCategory(Intent.CATEGORY_DEFAULT);
- if (Compatibility.runsAndTargetsTwentyFour(context)) {
- openIntent.setType("resource/folder");
- } else {
- openIntent.setDataAndType(Uri.parse("file://" + path), "resource/folder");
- }
- openIntent.putExtra("org.openintents.extra.ABSOLUTE_PATH", path);
-
- final Intent amazeIntent = new Intent(Intent.ACTION_VIEW);
- amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder");
-
- // will open a file manager at root and user can navigate themselves
- final Intent systemFallBack = new Intent(Intent.ACTION_VIEW);
- systemFallBack.addCategory(Intent.CATEGORY_DEFAULT);
- systemFallBack.setData(
- Uri.parse("content://com.android.externalstorage.documents/root/primary"));
-
- return Arrays.asList(openIntent, amazeIntent, systemFallBack);
+ notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, builder.build());
}
private static class Progress {
diff --git a/src/main/res/drawable/ic_folder_open_24dp.xml b/src/main/res/drawable/ic_folder_open_24dp.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9881c7748400cbf8037c92bfd11c0fea7454421c
--- /dev/null
+++ b/src/main/res/drawable/ic_folder_open_24dp.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index a7df4b226bd6e4a304b2a2a3d49b77034692c088..c8cfeb49493bbee6995daab50f06e1bfcbdc398a 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -331,7 +331,7 @@
Foreground service
Prevents the operating system from killing your connection
Create backup
- Backup files will be stored in %s
+ Backups will be stored in %s
Creating backup files
Your backup has been created
The backup files have been stored in %s
@@ -1107,4 +1107,5 @@
Enable customized notification settings (importance, sound, vibration) settings for this conversation?
Would you like to delete your avatar? Some clients might continue to display a cached copy of your avatar.
Show to contacts only
+ Backup location
diff --git a/src/main/res/xml/preferences_backup.xml b/src/main/res/xml/preferences_backup.xml
index b0362c7856b02349c266f04c63359e97e9634a51..f026103ceed70f37a2b150d0ec533912a9850e39 100644
--- a/src/main/res/xml/preferences_backup.xml
+++ b/src/main/res/xml/preferences_backup.xml
@@ -14,7 +14,9 @@
android:title="@string/pref_create_backup" />
+ android:icon="@drawable/ic_folder_open_24dp"
+ android:key="backup_location"
+ android:summary="@string/pref_create_backup_summary"
+ android:title="@string/pref_backup_location" />
\ No newline at end of file