diff --git a/build.gradle b/build.gradle
index 5734c8da8f1b98f2680a32f0e4d28b0ae95ef1f6..78695f275511fbdc2d40107b1d4b73daeb5a1be9 100644
--- a/build.gradle
+++ b/build.gradle
@@ -79,6 +79,7 @@ dependencies {
implementation "androidx.emoji2:emoji2-emojipicker:1.5.0"
implementation 'org.bouncycastle:bcmail-jdk18on:1.78.1'
+ implementation 'org.bouncycastle:bcpg-jdk18on:1.78.1'
implementation 'com.google.zxing:core:3.5.3'
implementation 'org.minidns:minidns-hla:1.1.1'
implementation 'me.leolin:ShortcutBadger:1.1.22@aar'
diff --git a/src/cheogram/AndroidManifest.xml b/src/cheogram/AndroidManifest.xml
index 57d2ec18d12baa41870e12d6e55d6eee408b750c..93d7893db79736ced22cc063d0de61e88dcda224 100644
--- a/src/cheogram/AndroidManifest.xml
+++ b/src/cheogram/AndroidManifest.xml
@@ -20,6 +20,8 @@
+
+
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);
+ }
+
+ public ExportBackupService(Context context, WorkerParameters workerParams) {
+ super(context, workerParams);
+ }
+
+ @Override
+ public Result doWork() {
+ setForegroundAsync(getForegroundInfo());
+ final List files;
+ try {
+ files = export();
+ } catch (final IOException | PGPException e) {
+ Log.d(Config.LOGTAG, "could not create backup", e);
+ return Result.failure();
+ } finally {
+ getApplicationContext()
+ .getSystemService(NotificationManager.class)
+ .cancel(NOTIFICATION_ID);
+ }
+ Log.d(Config.LOGTAG, "done creating " + files.size() + " backup files");
+ if (files.isEmpty()) {
+ return Result.success();
+ }
+ notifySuccess(files);
+ return Result.success();
+ }
+
+ @Override
+ public ForegroundInfo getForegroundInfo() {
+ Log.d(Config.LOGTAG, "getForegroundInfo()");
+ final NotificationCompat.Builder notification = getNotification();
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+ return new ForegroundInfo(
+ NOTIFICATION_ID,
+ notification.build(),
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
+ } else {
+ return new ForegroundInfo(NOTIFICATION_ID, notification.build());
+ }
+ }
+
+ private void messageExport(SQLiteDatabase db, Account account, PrintWriter writer, Progress progress) {
+ final var notificationManager = getApplicationContext().getSystemService(NotificationManager.class);
+ Cursor cursor = db.rawQuery("select conversations.*, messages.* from messages left join cheogram.messages using (uuid) join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=? order by timeSent", new String[]{account.getUuid()});
+ int size = cursor != null ? cursor.getCount() : 0;
+ Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + account.getUuid());
+ int i = 0;
+ int p = 0;
+ Element archive = new Element("archive", "urn:xmpp:pie:0#mam");
+ writer.write(archive.startTag().toString());
+ while (cursor != null && cursor.moveToNext()) {
+ try {
+ final Conversation conversation = Conversation.fromCursor(cursor);
+ Message m = Message.fromCursor(cursor, conversation);
+ Element result = new Element("result", "urn:xmpp:mam:2");
+ if (m.getServerMsgId() != null) result.setAttribute("id", m.getServerMsgId());
+ Element forwarded = new Element("forwarded", "urn:xmpp:forward:0");
+ final SimpleDateFormat mDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
+ mDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+ Element delay = forwarded.addChild("delay", "urn:xmpp:delay");
+ Date date = new Date(m.getTimeSent());
+ delay.setAttribute("stamp", mDateFormat.format(date));
+ // TODO: time received?
+
+ Element message = new Element("message", "jabber:client").setAttribute("type", conversation.getMode() == Conversation.MODE_MULTI && m.getType() != Message.TYPE_PRIVATE && m.getType() != Message.TYPE_PRIVATE_FILE ? "groupchat" : "chat");
+ String outerId = null;
+ if (m.getStatus() <= Message.STATUS_RECEIVED) {
+ message.setAttribute("to", account.getJid()).setAttribute("from", m.getCounterpart());
+ if (m.getRemoteMsgId() != null) outerId = m.getRemoteMsgId();
+ } else {
+ message.setAttribute("from", account.getJid()).setAttribute("to", m.getCounterpart());
+ outerId = m.getUuid();
+ }
+ if (outerId != null) message.setAttribute("id", outerId);
+ if (m.getRawBody() != null) message.addChild(new Element("body").setContent(m.getRawBody()));
+ if (m.getSubject() != null) message.addChild(new Element("subject").setContent(m.getSubject()));
+ if (conversation.getMode() == Conversation.MODE_MULTI) {
+ final var x = new Element("x", "http://jabber.org/protocol/muc#user");
+ if (m.getTrueCounterpart() != null) x.addChild("item", "http://jabber.org/protocol/muc#user").setAttribute("jid", m.getTrueCounterpart());
+ message.addChild(x);
+ if (m.getOccupantId() != null) message.addChild("occupant-id", "urn:xmpp:occupant-id:0").setAttribute("id", m.getOccupantId());
+ }
+ message.addChildren(m.getPayloads());
+ forwarded.addChild(message);
+ result.addChild(forwarded);
+ writer.write(result.toString());
+ final HashMultimap aggregatedReactions = HashMultimap.create();
+ for (final var reaction : m.getReactions()) {
+ aggregatedReactions.put(reaction.occupantId == null ? (reaction.trueJid == null ? reaction.from.toString() : reaction.trueJid.toString()) : reaction.occupantId, reaction);
+ }
+ for (final var reactionSet : aggregatedReactions.asMap().values()) {
+ final var reaction = reactionSet.iterator().next();
+ result = new Element("result", "urn:xmpp:mam:2");
+ forwarded = new Element("forwarded", "urn:xmpp:forward:0");
+ message = new Element("message", "jabber:client").setAttribute("type", conversation.getMode() == Conversation.MODE_MULTI && m.getType() != Message.TYPE_PRIVATE && m.getType() != Message.TYPE_PRIVATE_FILE ? "groupchat" : "chat");
+ message.setAttribute("from", reaction.from).setAttribute("to", reaction.received && conversation.getMode() != Conversation.MODE_MULTI ? account.getJid() : m.getCounterpart());
+ if (reaction.envelopeId != null) message.setAttribute("id", reaction.envelopeId);
+ final var reactionsEl = new Element("reactions", "urn:xmpp:reactions:0");
+ reactionsEl.setAttribute("id", "groupchat".equals(message.getAttribute("type")) ? m.getServerMsgId() : outerId);
+ for (final var r : reactionSet) {
+ reactionsEl.addChild("reaction", "urn:xmpp:reactions:0").setContent(r.reaction);
+ }
+ message.addChild(reactionsEl);
+ if (conversation.getMode() == Conversation.MODE_MULTI) {
+ final var x = new Element("x", "http://jabber.org/protocol/muc#user");
+ if (reaction.trueJid != null) x.addChild("item", "http://jabber.org/protocol/muc#user").setAttribute("jid", reaction.trueJid);
+ message.addChild(x);
+ if (reaction.occupantId != null) message.addChild("occupant-id", "urn:xmpp:occupant-id:0").setAttribute("id", reaction.occupantId);
+ }
+ forwarded.addChild(message);
+ result.addChild(forwarded);
+ writer.write(result.toString());
+ }
+ } catch (final Exception e) {
+ Log.e(Config.LOGTAG, "message export error: " + e);
+ }
+ if (i + PAGE_SIZE > size) {
+ i = size;
+ } else {
+ i += PAGE_SIZE;
+ }
+ final int percentage = i * 100 / size;
+ if (p < percentage) {
+ p = percentage;
+ notificationManager.notify(NOTIFICATION_ID, progress.build(p));
+ }
+ }
+ if (cursor != null) {
+ cursor.close();
+ }
+ writer.write(archive.endTag().toString());
+ }
+
+ private void messageExportCheogram(SQLiteDatabase db, Account account, PrintWriter writer, Progress progress) {
+ final var notificationManager = getApplicationContext().getSystemService(NotificationManager.class);
+ int i = 0;
+ int p = 0;
+ Cursor cursor = db.rawQuery("select conversations.*,webxdc_updates.* from " + Conversation.TABLENAME + " join cheogram.webxdc_updates webxdc_updates on " + Conversation.TABLENAME + ".uuid=webxdc_updates." + Message.CONVERSATION + " where conversations.accountUuid=?", new String[]{account.getUuid()});
+ int size = cursor != null ? cursor.getCount() : 0;
+ Log.d(Config.LOGTAG, "exporting " + size + " WebXDC updates for account " + account.getUuid());
+ while (cursor != null && cursor.moveToNext()) {
+ final Conversation conversation = Conversation.fromCursor(cursor);
+ Element result = new Element("result", "urn:xmpp:mam:2");
+ result.setAttribute("id", "webxdc-serial:" + cursor.getString(cursor.getColumnIndex("serial")));
+ Element forwarded = new Element("forwarded", "urn:xmpp:forward:0");
+ Element message = new Element("message", "jabber:client").setAttribute("type", conversation.getMode() == Conversation.MODE_MULTI ? "groupchat" : "chat");
+ final var sender = cursor.getString(cursor.getColumnIndex("sender"));
+ message.setAttribute("from", sender);
+ message.setAttribute("to", !account.getJid().toString().equals(sender) && conversation.getMode() != Conversation.MODE_MULTI ? account.getJid() : conversation.getJid());
+ final var info = cursor.getString(cursor.getColumnIndex("info"));
+ if (info != null) message.addChild(new Element("body").setContent(info));
+ final var thread = cursor.getString(cursor.getColumnIndex("thread"));
+ if (thread != null) {
+ final var threadParent = cursor.getString(cursor.getColumnIndex("threadParent"));
+ final var threadEl = new Element("thread").setContent(thread);
+ if (threadParent != null) threadEl.setAttribute("parent", threadParent);
+ message.addChild(threadEl);
+ }
+ final var x = new Element("x", "urn:xmpp:webxdc:0");
+ final var document = cursor.getString(cursor.getColumnIndex("document"));
+ if (document != null) x.addChild("document", "urn:xmpp:webxdc:0").setContent(document);
+ final var summary = cursor.getString(cursor.getColumnIndex("summary"));
+ if (summary != null) x.addChild("document", "urn:xmpp:webxdc:0").setContent(summary);
+ final var payload = cursor.getString(cursor.getColumnIndex("payload"));
+ if (payload != null) x.addChild("json", "urn:xmpp:json:0").setContent(payload);
+ forwarded.addChild(message);
+ result.addChild(forwarded);
+ writer.write(result.toString());
+
+ if (i + PAGE_SIZE > size) {
+ i = size;
+ } else {
+ i += PAGE_SIZE;
+ }
+ final int percentage = i * 100 / size;
+ if (p < percentage) {
+ p = percentage;
+ notificationManager.notify(NOTIFICATION_ID, progress.build(p));
+ }
+ }
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+
+ private List export() throws IOException, PGPException {
+ final Context context = getApplicationContext();
+ final var database = DatabaseBackend.getInstance(context);
+ final var accounts = database.getAccounts();
+ final var notification = getNotification();
+ int count = 0;
+ final int max = accounts.size();
+ final List files = new ArrayList<>();
+ Log.d(Config.LOGTAG, "starting backup for " + max + " accounts");
+ for (final Account account : accounts) {
+ final String password = account.getPassword();
+ if (Strings.nullToEmpty(password).trim().isEmpty()) {
+ Log.d(Config.LOGTAG, String.format("skipping backup for %s because password is empty. unable to encrypt", account.getJid().asBareJid()));
+ continue;
+ }
+ Log.d(Config.LOGTAG, String.format("exporting data for account %s (%s)", account.getJid().asBareJid(), account.getUuid()));
+ final Progress progress = new Progress(notification, max, count);
+ final File file = new File(FileBackend.getBackupDirectory(context), account.getJid().asBareJid().toEscapedString() + ".xml.pgp");
+ files.add(file);
+ final File directory = file.getParentFile();
+ if (directory != null && directory.mkdirs()) {
+ Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
+ }
+ final FileOutputStream fileOutputStream = new FileOutputStream(file);
+
+ PGPLiteralDataGenerator lData = new PGPLiteralDataGenerator();
+ PGPCompressedDataGenerator comData = new PGPCompressedDataGenerator(PGPCompressedDataGenerator.ZLIB);
+ PGPEncryptedDataGenerator encGen = new PGPEncryptedDataGenerator(
+ new JcePGPDataEncryptorBuilder(PGPEncryptedDataGenerator.AES_256).setUseV6AEAD().setWithAEAD(PGPEncryptedData.GCM, 16)
+ );
+ encGen.setForceSessionKey(true);
+ encGen.addMethod(new BcPBEKeyEncryptionMethodGenerator(
+ password.toCharArray(),
+ Argon2Params.memoryConstrainedParameters()
+ ).setSecureRandom(new SecureRandom()));
+
+ PrintWriter writer = new PrintWriter(lData.open(
+ comData.open(
+ encGen.open(fileOutputStream, new byte[4096]),
+ new byte[4096]
+ ),
+ PGPLiteralDataGenerator.UTF8,
+ account.getJid().asBareJid().toEscapedString() + ".xml",
+ PGPLiteralDataGenerator.NOW,
+ new byte[4096]
+ ));
+
+ Element serverData = new Element("server-data", "urn:xmpp:pie:0");
+ Element host = new Element("host").setAttribute("jid", account.getDomain());
+ Element user = new Element("user").setAttribute("name", account.getUsername());
+
+ writer.write(serverData.startTag().toString());
+ writer.write(host.startTag().toString());
+ writer.write(user.startTag().toString());
+
+ Element roster = new Element("query", "jabber:iq:roster");
+ if (!"".equals(account.getRosterVersion())) roster.setAttribute("ver", account.getRosterVersion());
+ // TODO: conversations, contacts, bookmarks?
+ writer.write(roster.toString());
+
+ if (account.getDisplayName() != null && !"".equals(account.getDisplayName())) {
+ Element nickname = new Element("pubsub", "http://jabber.org/protocol/pubsub#owner");
+ Element nickItems = new Element("items").setAttribute("node", "http://jabber.org/protocol/nick");
+ Element nickItem = new Element("item");
+ nickItem.addChild(new Element("nick", "http://jabber.org/protocol/nick").setContent(account.getDisplayName()));
+ nickItems.addChild(nickItem);
+ nickname.addChild(nickItems);
+ writer.write(nickname.toString());
+ }
+
+ if (account.getAvatar() != null) {
+ Element avatar = new Element("pubsub", "http://jabber.org/protocol/pubsub#owner");
+ Element avatarItems = new Element("items").setAttribute("node", "urn:xmpp:avatar:metadata");
+ Element avatarItem = new Element("item").setAttribute("id", account.getAvatar());
+ Element avatarMeta = new Element("metadata", "urn:xmpp:avatar:metadata");
+ avatarMeta.addChild(new Element("info").setAttribute("id", account.getAvatar()));
+ avatarItem.addChild(avatarMeta);
+ avatarItems.addChild(avatarItem);
+ avatar.addChild(avatarItems);
+ writer.write(avatar.toString());
+ final var f = new File(context.getCacheDir(), "/avatars/" + account.getAvatar());
+ if (f.canRead()) {
+ final var byteArrayOutputStream = new ByteArrayOutputStream();
+ final var base64OutputStream = new Base64OutputStream(byteArrayOutputStream, Base64.DEFAULT);
+ ByteStreams.copy(new FileInputStream(f), base64OutputStream);
+ base64OutputStream.flush();
+ base64OutputStream.close();
+
+ Element avatar2 = new Element("pubsub", "http://jabber.org/protocol/pubsub#owner");
+ Element avatarItems2 = new Element("items").setAttribute("node", "urn:xmpp:avatar:data");
+ Element avatarItem2 = new Element("item").setAttribute("id", account.getAvatar());
+ Element avatarData = new Element("data", "urn:xmpp:avatar:data");
+ avatarData.setContent(new String(byteArrayOutputStream.toByteArray()));
+ avatarItem2.addChild(avatarData);
+ avatarItems2.addChild(avatarItem2);
+ avatar2.addChild(avatarItems2);
+ writer.write(avatar2.toString());
+ }
+ }
+
+ SQLiteDatabase db = database.getReadableDatabase();
+ final String uuid = account.getUuid();
+ messageExport(db, account, writer, progress);
+ messageExportCheogram(db, account, writer, progress);
+
+ writer.write(user.endTag().toString());
+ writer.write(host.endTag().toString());
+ writer.write(serverData.endTag().toString());
+
+ writer.flush();
+ writer.close();
+ lData.close();
+ comData.close();
+ encGen.close();
+ fileOutputStream.flush();
+ fileOutputStream.close();
+ Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
+ count++;
+ }
+ return files;
+ }
+
+ private void notifySuccess(final List files) {
+ final var context = getApplicationContext();
+ final String path = FileBackend.getBackupDirectory(context).getAbsolutePath();
+
+ final var openFolderIntent = getOpenFolderIntent(path);
+
+ final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
+ final ArrayList uris = new ArrayList<>();
+ for (final File file : files) {
+ uris.add(FileBackend.getUriForFile(context, file));
+ }
+ intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ intent.setType("application/pgp-encrypted");
+ final Intent chooser =
+ Intent.createChooser(intent, context.getString(R.string.share_backup_files));
+ 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))
+ .setContentText(
+ context.getString(R.string.notification_backup_created_subtitle, path))
+ .setStyle(
+ new NotificationCompat.BigTextStyle()
+ .bigText(
+ context.getString(
+ R.string.notification_backup_created_subtitle,
+ FileBackend.getBackupDirectory(context)
+ .getAbsolutePath())))
+ .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(
+ R.drawable.ic_share_24dp,
+ context.getString(R.string.share_backup_files),
+ shareFilesIntent);
+ final var notificationManager = context.getSystemService(NotificationManager.class);
+ notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, mBuilder.build());
+ }
+
+ private Optional getOpenFolderIntent(final String path) {
+ final var context = getApplicationContext();
+ for (final Intent intent : getPossibleFileOpenIntents(context, path)) {
+ if (intent.resolveActivityInfo(context.getPackageManager(), 0) != null) {
+ return Optional.of(
+ PendingIntent.getActivity(context, 189, intent, PENDING_INTENT_FLAGS));
+ }
+ }
+ return Optional.absent();
+ }
+
+ private NotificationCompat.Builder getNotification() {
+ final var context = getApplicationContext();
+ final NotificationCompat.Builder notification =
+ new NotificationCompat.Builder(context, "backup");
+ notification
+ .setContentTitle(context.getString(R.string.notification_create_backup_title))
+ .setSmallIcon(R.drawable.ic_archive_24dp)
+ .setProgress(1, 0, false);
+ notification.setOngoing(true);
+ notification.setLocalOnly(true);
+ return notification;
+ }
+
+ private static class Progress {
+ private final NotificationCompat.Builder builder;
+ private final int max;
+ private final int count;
+
+ private Progress(NotificationCompat.Builder builder, int max, int count) {
+ this.builder = builder;
+ this.max = max;
+ this.count = count;
+ }
+
+ private Notification build(int percentage) {
+ builder.setProgress(max * 100, count * 100 + percentage, false);
+ return builder.build();
+ }
+ }
+}
diff --git a/src/cheogram/res/values/strings.xml b/src/cheogram/res/values/strings.xml
index ffe442874d4e62e5f910df5f94d110d1186573b8..89e8d8ab4d6e4bcb84db678c62a31383a36c6073 100644
--- a/src/cheogram/res/values/strings.xml
+++ b/src/cheogram/res/values/strings.xml
@@ -51,4 +51,6 @@
Hide chats in Chat Requests area
Integrate Browser UI
Ask your browser to render as if integrated with this app ("custom tab")
+ Export Data (experimental)
+ Export data useful for importing into another app. Not a full backup.
diff --git a/src/cheogramCatappult b/src/cheogramCatappult
new file mode 120000
index 0000000000000000000000000000000000000000..7f945e6852980dbb974b76e9102413681f23dd0b
--- /dev/null
+++ b/src/cheogramCatappult
@@ -0,0 +1 @@
+cheogramPlaystore
\ No newline at end of file
diff --git a/src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java b/src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java
index 739598a69009561a7f6a6bec4076fc6530d31c32..7038f410c4281b2f1bc635707fca00a54267d8ab 100644
--- a/src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java
+++ b/src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java
@@ -60,6 +60,7 @@ public class BackupSettingsFragment extends XmppPreferenceFragment {
public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) {
setPreferencesFromResource(R.xml.preferences_backup, rootKey);
final var createOneOffBackup = findPreference(CREATE_ONE_OFF_BACKUP);
+ final var export = findPreference("export");
final ListPreference recurringBackup = findPreference(RECURRING_BACKUP);
final var backupDirectory = findPreference("backup_directory");
if (createOneOffBackup == null || recurringBackup == null || backupDirectory == null) {
@@ -71,6 +72,7 @@ public class BackupSettingsFragment extends XmppPreferenceFragment {
R.string.pref_create_backup_summary,
FileBackend.getBackupDirectory(requireContext()).getAbsolutePath()));
createOneOffBackup.setOnPreferenceClickListener(this::onBackupPreferenceClicked);
+ export.setOnPreferenceClickListener(this::onExportClicked);
setValues(
recurringBackup,
R.array.recurring_backup_values,
@@ -157,4 +159,34 @@ public class BackupSettingsFragment extends XmppPreferenceFragment {
builder.setPositiveButton(R.string.ok, null);
builder.create().show();
}
+
+ private boolean onExportClicked(final Preference preference) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ if (ContextCompat.checkSelfPermission(
+ requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ != PackageManager.PERMISSION_GRANTED) {
+ requestStorageForBackupLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ } else {
+ startExport();
+ }
+ } else {
+ startExport();
+ }
+ return true;
+ }
+
+ private void startExport() {
+ final OneTimeWorkRequest exportBackupWorkRequest =
+ new OneTimeWorkRequest.Builder(com.cheogram.android.ExportBackupService.class)
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .build();
+ WorkManager.getInstance(requireContext())
+ .enqueueUniqueWork(
+ CREATE_ONE_OFF_BACKUP, ExistingWorkPolicy.KEEP, exportBackupWorkRequest);
+ final MaterialAlertDialogBuilder builder =
+ new MaterialAlertDialogBuilder(requireActivity());
+ builder.setMessage(R.string.backup_started_message);
+ builder.setPositiveButton(R.string.ok, null);
+ builder.create().show();
+ }
}
diff --git a/src/main/java/eu/siacs/conversations/xml/Element.java b/src/main/java/eu/siacs/conversations/xml/Element.java
index 45431f0ffcdc0711752b1594d38b2bd722c466bd..3371bdf081c785450e90bebef4edcc3340865545 100644
--- a/src/main/java/eu/siacs/conversations/xml/Element.java
+++ b/src/main/java/eu/siacs/conversations/xml/Element.java
@@ -216,26 +216,39 @@ public class Element implements Node {
public String toString(final ImmutableMap parentNS) {
final var mutns = new Hashtable<>(parentNS);
- final var attr = getSerializableAttributes(mutns);
final StringBuilder elementOutput = new StringBuilder();
if (childNodes.size() == 0) {
+ final var attr = getSerializableAttributes(mutns);
Tag emptyTag = Tag.empty(name);
emptyTag.setAttributes(attr);
elementOutput.append(emptyTag.toString());
} else {
final var ns = ImmutableMap.copyOf(mutns);
- Tag startTag = Tag.start(name);
- startTag.setAttributes(attr);
+ final var startTag = startTag(mutns);
elementOutput.append(startTag);
for (Node child : ImmutableList.copyOf(childNodes)) {
elementOutput.append(child.toString(ns));
}
- Tag endTag = Tag.end(name);
- elementOutput.append(endTag);
+ elementOutput.append(endTag());
}
return elementOutput.toString();
}
+ public Tag startTag() {
+ return startTag(new Hashtable<>());
+ }
+
+ public Tag startTag(final Hashtable mutns) {
+ final var attr = getSerializableAttributes(mutns);
+ final var startTag = Tag.start(name);
+ startTag.setAttributes(attr);
+ return startTag;
+ }
+
+ public Tag endTag() {
+ return Tag.end(name);
+ }
+
protected Hashtable getSerializableAttributes(Hashtable ns) {
final var result = new Hashtable();
for (final var attr : attributes.entrySet()) {
diff --git a/src/main/res/drawable/attach_file.xml b/src/main/res/drawable/attach_file.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a368291183449b090556c457703baaf696787356
--- /dev/null
+++ b/src/main/res/drawable/attach_file.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/src/main/res/xml/preferences_backup.xml b/src/main/res/xml/preferences_backup.xml
index b0362c7856b02349c266f04c63359e97e9634a51..c2e271d749e427ab8ed3e8e7cf98676fced8b05c 100644
--- a/src/main/res/xml/preferences_backup.xml
+++ b/src/main/res/xml/preferences_backup.xml
@@ -13,8 +13,14 @@
android:summary="@string/pref_create_backup_one_off_summary"
android:title="@string/pref_create_backup" />
+
+
-
\ No newline at end of file
+