add ability to pick custom backup location

Daniel Gultsch created

Change summary

src/main/AndroidManifest.xml                                                          |   5 
src/main/java/eu/siacs/conversations/AppSettings.java                                 |  52 
src/main/java/eu/siacs/conversations/crypto/axolotl/SQLiteAxolotlStore.java           | 955 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java              |   4 
src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java |  49 
src/main/java/eu/siacs/conversations/utils/BackupFile.java                            |  49 
src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java                   | 173 
src/main/res/drawable/ic_folder_open_24dp.xml                                         |  10 
src/main/res/values/strings.xml                                                       |   3 
src/main/res/xml/preferences_backup.xml                                               |   6 
10 files changed, 735 insertions(+), 571 deletions(-)

Detailed changes

src/main/AndroidManifest.xml 🔗

@@ -70,10 +70,6 @@
         <intent>
             <action android:name="eu.siacs.conversations.location.show" />
         </intent>
-        <intent>
-            <action android:name="android.intent.action.VIEW" />
-            <data android:mimeType="resource/folder" />
-        </intent>
         <intent>
             <action android:name="android.intent.action.VIEW" />
         </intent>
@@ -82,7 +78,6 @@
         </intent>
     </queries>
 
-
     <application
         android:name=".Conversations"
         android:allowBackup="true"

src/main/java/eu/siacs/conversations/AppSettings.java 🔗

@@ -3,10 +3,14 @@ package eu.siacs.conversations;
 import android.content.Context;
 import android.content.SharedPreferences;
 import android.net.Uri;
+import android.os.Environment;
 import androidx.annotation.BoolRes;
 import androidx.annotation.NonNull;
 import androidx.preference.PreferenceManager;
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
+import eu.siacs.conversations.persistance.FileBackend;
 import eu.siacs.conversations.services.QuickConversationsService;
 import java.security.SecureRandom;
 
@@ -46,10 +50,14 @@ public class AppSettings {
     public static final String SHOW_AVATARS = "show_avatars";
     public static final String CALL_INTEGRATION = "call_integration";
     public static final String ALIGN_START = "align_start";
+    public static final String BACKUP_LOCATION = "backup_location";
 
     private static final String ACCEPT_INVITES_FROM_STRANGERS = "accept_invites_from_strangers";
     private static final String INSTALLATION_ID = "im.conversations.android.install_id";
 
+    private static final String EXTERNAL_STORAGE_AUTHORITY =
+            "com.android.externalstorage.documents";
+
     private final Context context;
 
     public AppSettings(final Context context) {
@@ -150,6 +158,50 @@ public class AppSettings {
                 OMEMO, context.getString(R.string.omemo_setting_default));
     }
 
+    public Uri getBackupLocation() {
+        final SharedPreferences sharedPreferences =
+                PreferenceManager.getDefaultSharedPreferences(context);
+        final String location = sharedPreferences.getString(BACKUP_LOCATION, null);
+        if (Strings.isNullOrEmpty(location)) {
+            final var directory = FileBackend.getBackupDirectory(context);
+            return Uri.fromFile(directory);
+        }
+        return Uri.parse(location);
+    }
+
+    public String getBackupLocationAsPath() {
+        return asPath(getBackupLocation());
+    }
+
+    public static String asPath(final Uri uri) {
+        final var scheme = uri.getScheme();
+        final var path = uri.getPath();
+        if (path == null) {
+            return uri.toString();
+        }
+        if ("file".equalsIgnoreCase(scheme)) {
+            return path;
+        } else if ("content".equalsIgnoreCase(scheme)) {
+            if (EXTERNAL_STORAGE_AUTHORITY.equalsIgnoreCase(uri.getAuthority())) {
+                final var parts = Splitter.on(':').limit(2).splitToList(path);
+                if (parts.size() == 2 && "/tree/primary".equals(parts.get(0))) {
+                    return Joiner.on('/')
+                            .join(Environment.getExternalStorageDirectory(), parts.get(1));
+                }
+            }
+        }
+        return uri.toString();
+    }
+
+    public void setBackupLocation(final Uri uri) {
+        final SharedPreferences sharedPreferences =
+                PreferenceManager.getDefaultSharedPreferences(context);
+        sharedPreferences
+                .edit()
+                .putString(BACKUP_LOCATION, uri == null ? "" : uri.toString())
+                .apply();
+    }
+
     public boolean isSendCrashReports() {
         return getBooleanPreference(SEND_CRASH_REPORTS, R.bool.send_crash_reports);
     }

src/main/java/eu/siacs/conversations/crypto/axolotl/SQLiteAxolotlStore.java 🔗

@@ -2,7 +2,14 @@ package eu.siacs.conversations.crypto.axolotl;
 
 import android.util.Log;
 import android.util.LruCache;
-
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.utils.CryptoHelper;
+import java.security.cert.X509Certificate;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
 import org.whispersystems.libsignal.IdentityKey;
 import org.whispersystems.libsignal.IdentityKeyPair;
 import org.whispersystems.libsignal.InvalidKeyIdException;
@@ -15,463 +22,495 @@ import org.whispersystems.libsignal.state.SignalProtocolStore;
 import org.whispersystems.libsignal.state.SignedPreKeyRecord;
 import org.whispersystems.libsignal.util.KeyHelper;
 
-import java.security.cert.X509Certificate;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-import eu.siacs.conversations.Config;
-import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.services.XmppConnectionService;
-import eu.siacs.conversations.utils.CryptoHelper;
-
 public class SQLiteAxolotlStore implements SignalProtocolStore {
 
-	public static final String PREKEY_TABLENAME = "prekeys";
-	public static final String SIGNED_PREKEY_TABLENAME = "signed_prekeys";
-	public static final String SESSION_TABLENAME = "sessions";
-	public static final String IDENTITIES_TABLENAME = "identities";
-	public static final String ACCOUNT = "account";
-	public static final String DEVICE_ID = "device_id";
-	public static final String ID = "id";
-	public static final String KEY = "key";
-	public static final String FINGERPRINT = "fingerprint";
-	public static final String NAME = "name";
-	public static final String TRUSTED = "trusted"; //no longer used
-	public static final String TRUST = "trust";
-	public static final String ACTIVE = "active";
-	public static final String LAST_ACTIVATION = "last_activation";
-	public static final String OWN = "ownkey";
-	public static final String CERTIFICATE = "certificate";
-
-	public static final String JSONKEY_REGISTRATION_ID = "axolotl_reg_id";
-	public static final String JSONKEY_CURRENT_PREKEY_ID = "axolotl_cur_prekey_id";
-
-	private static final int NUM_TRUSTS_TO_CACHE = 100;
-
-	private final Account account;
-	private final XmppConnectionService mXmppConnectionService;
-
-	private IdentityKeyPair identityKeyPair;
-	private int localRegistrationId;
-	private int currentPreKeyId = 0;
-
-	private final HashSet<Integer> preKeysMarkedForRemoval = new HashSet<>();
-
-	private final LruCache<String, FingerprintStatus> trustCache =
-			new LruCache<String, FingerprintStatus>(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.
-	 * <p/>
-	 * 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
-	 * <p/>
-	 * 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(SignalProtocolAddress address, 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.blindTrustBeforeVerification() && !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.
-	 * <p/>
-	 * 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<IdentityKey> 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.
-	 * <p/>
-	 * 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<Integer> getSubDeviceSessions(String name) {
-		return mXmppConnectionService.databaseBackend.getSubDeviceSessions(account,
-				new SignalProtocolAddress(name, 0));
-	}
-
-
-	public List<String> 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<SignedPreKeyRecord> 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());
-	}
+    public static final String PREKEY_TABLENAME = "prekeys";
+    public static final String SIGNED_PREKEY_TABLENAME = "signed_prekeys";
+    public static final String SESSION_TABLENAME = "sessions";
+    public static final String IDENTITIES_TABLENAME = "identities";
+    public static final String ACCOUNT = "account";
+    public static final String DEVICE_ID = "device_id";
+    public static final String ID = "id";
+    public static final String KEY = "key";
+    public static final String FINGERPRINT = "fingerprint";
+    public static final String NAME = "name";
+    public static final String TRUSTED = "trusted"; // no longer used
+    public static final String TRUST = "trust";
+    public static final String ACTIVE = "active";
+    public static final String LAST_ACTIVATION = "last_activation";
+    public static final String OWN = "ownkey";
+    public static final String CERTIFICATE = "certificate";
+
+    public static final String JSONKEY_REGISTRATION_ID = "axolotl_reg_id";
+    public static final String JSONKEY_CURRENT_PREKEY_ID = "axolotl_cur_prekey_id";
+
+    private static final int NUM_TRUSTS_TO_CACHE = 100;
+
+    private final Account account;
+    private final XmppConnectionService mXmppConnectionService;
+
+    private IdentityKeyPair identityKeyPair;
+    private int localRegistrationId;
+    private int currentPreKeyId = 0;
+
+    private final HashSet<Integer> preKeysMarkedForRemoval = new HashSet<>();
+
+    private final LruCache<String, FingerprintStatus> trustCache =
+            new LruCache<String, FingerprintStatus>(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.
+     *
+     * <p>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
+     *
+     * <p>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.
+     *
+     * <p>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<IdentityKey> 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.
+     *
+     * <p>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<Integer> getSubDeviceSessions(String name) {
+        return mXmppConnectionService.databaseBackend.getSubDeviceSessions(
+                account, new SignalProtocolAddress(name, 0));
+    }
+
+    public List<String> 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<SignedPreKeyRecord> 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());
+    }
 }

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;
     }

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<Uri> 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);

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<BackupFile> {
         final var backupFiles = new ImmutableList.Builder<BackupFile>();
         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<File> 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<BackupFile> {
     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();
     }
 }

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<File> files;
+        final List<Uri> files;
         try {
             files = export();
         } catch (final IOException
@@ -132,7 +134,7 @@ public class ExportBackupWorker extends Worker {
         }
     }
 
-    private List<File> export()
+    private List<Uri> 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<File> files = new ImmutableList.Builder<>();
+        final ImmutableList.Builder<Uri> 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<File> files) {
+    private void notifySuccess(final List<Uri> 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<Uri> 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<PendingIntent> 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<Intent> 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 {

src/main/res/drawable/ic_folder_open_24dp.xml 🔗

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:tint="?attr/colorControlNormal"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+    <path
+        android:fillColor="@android:color/white"
+        android:pathData="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L160,320L160,720Q160,720 160,720Q160,720 160,720L256,400L940,400L837,743Q829,769 807.5,784.5Q786,800 760,800L160,800Z" />
+</vector>

src/main/res/values/strings.xml 🔗

@@ -331,7 +331,7 @@
     <string name="pref_keep_foreground_service">Foreground service</string>
     <string name="pref_keep_foreground_service_summary">Prevents the operating system from killing your connection</string>
     <string name="pref_create_backup">Create backup</string>
-    <string name="pref_create_backup_summary">Backup files will be stored in %s</string>
+    <string name="pref_create_backup_summary">Backups will be stored in %s</string>
     <string name="notification_create_backup_title">Creating backup files</string>
     <string name="notification_backup_created_title">Your backup has been created</string>
     <string name="notification_backup_created_subtitle">The backup files have been stored in %s</string>
@@ -1107,4 +1107,5 @@
     <string name="custom_notifications_enable">Enable customized notification settings (importance, sound, vibration) settings for this conversation?</string>
     <string name="delete_avatar_message">Would you like to delete your avatar? Some clients might continue to display a cached copy of your avatar.</string>
     <string name="show_to_contacts_only">Show to contacts only</string>
+    <string name="pref_backup_location">Backup location</string>
 </resources>

src/main/res/xml/preferences_backup.xml 🔗

@@ -14,7 +14,9 @@
         android:title="@string/pref_create_backup" />
 
     <Preference
-        android:key="backup_directory"
-        android:summary="@string/pref_create_backup_summary" />
+        android:icon="@drawable/ic_folder_open_24dp"
+        android:key="backup_location"
+        android:summary="@string/pref_create_backup_summary"
+        android:title="@string/pref_backup_location" />
 
 </PreferenceScreen>