Detailed changes
@@ -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"
@@ -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);
}
@@ -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());
+ }
}
@@ -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;
}
@@ -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);
@@ -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();
}
}
@@ -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 {
@@ -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>
@@ -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>
@@ -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>