ExportBackupService.java

  1package com.cheogram.android;
  2
  3import static eu.siacs.conversations.utils.Compatibility.s;
  4
  5import android.app.Notification;
  6import android.app.NotificationManager;
  7import android.app.PendingIntent;
  8import android.app.Service;
  9import android.content.Context;
 10import android.content.Intent;
 11import android.content.pm.ServiceInfo;
 12import android.database.Cursor;
 13import android.database.DatabaseUtils;
 14import android.database.sqlite.SQLiteDatabase;
 15import android.net.Uri;
 16import android.os.IBinder;
 17import android.util.Base64;
 18import android.util.Base64OutputStream;
 19import android.util.Log;
 20
 21import androidx.core.app.NotificationCompat;
 22import androidx.work.ForegroundInfo;
 23import androidx.work.WorkManager;
 24import androidx.work.Worker;
 25import androidx.work.WorkerParameters;
 26
 27import com.google.common.base.CharMatcher;
 28import com.google.common.base.Optional;
 29import com.google.common.base.Strings;
 30import com.google.common.collect.HashMultimap;
 31import com.google.common.collect.ImmutableList;
 32import com.google.common.collect.ImmutableMap;
 33import com.google.common.io.ByteStreams;
 34
 35import java.io.ByteArrayOutputStream;
 36import java.io.DataOutputStream;
 37import java.io.File;
 38import java.io.FileInputStream;
 39import java.io.FileOutputStream;
 40import java.io.IOException;
 41import java.io.PrintWriter;
 42import java.security.NoSuchAlgorithmException;
 43import java.security.SecureRandom;
 44import java.security.spec.InvalidKeySpecException;
 45import java.text.SimpleDateFormat;
 46import java.util.ArrayList;
 47import java.util.Arrays;
 48import java.util.Collections;
 49import java.util.Date;
 50import java.util.List;
 51import java.util.Locale;
 52import java.util.TimeZone;
 53import java.util.concurrent.atomic.AtomicBoolean;
 54
 55import org.bouncycastle.bcpg.S2K.Argon2Params;
 56import org.bouncycastle.openpgp.PGPCompressedDataGenerator;
 57import org.bouncycastle.openpgp.PGPEncryptedData;
 58import org.bouncycastle.openpgp.PGPEncryptedDataGenerator;
 59import org.bouncycastle.openpgp.PGPException;
 60import org.bouncycastle.openpgp.PGPLiteralDataGenerator;
 61import org.bouncycastle.openpgp.operator.bc.BcPBEKeyEncryptionMethodGenerator;
 62import org.bouncycastle.openpgp.operator.jcajce.JcePGPDataEncryptorBuilder;
 63
 64import eu.siacs.conversations.Config;
 65import eu.siacs.conversations.R;
 66import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
 67import eu.siacs.conversations.entities.Account;
 68import eu.siacs.conversations.entities.Conversation;
 69import eu.siacs.conversations.entities.Message;
 70import eu.siacs.conversations.entities.Reaction;
 71import eu.siacs.conversations.persistance.DatabaseBackend;
 72import eu.siacs.conversations.persistance.FileBackend;
 73import eu.siacs.conversations.utils.BackupFileHeader;
 74import eu.siacs.conversations.utils.Compatibility;
 75import eu.siacs.conversations.xml.Element;
 76
 77public class ExportBackupService extends Worker {
 78
 79    private static final int NOTIFICATION_ID = 19;
 80    private static final int PAGE_SIZE = 20;
 81    private static final int BACKUP_CREATED_NOTIFICATION_ID = 23;
 82    private static final int PENDING_INTENT_FLAGS =
 83            s()
 84                    ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
 85                    : PendingIntent.FLAG_UPDATE_CURRENT;
 86
 87    private static List<Intent> getPossibleFileOpenIntents(final Context context, final String path) {
 88
 89        //http://www.openintents.org/action/android-intent-action-view/file-directory
 90        //do not use 'vnd.android.document/directory' since this will trigger system file manager
 91        final Intent openIntent = new Intent(Intent.ACTION_VIEW);
 92        openIntent.addCategory(Intent.CATEGORY_DEFAULT);
 93        if (android.os.Build.VERSION.SDK_INT >= 24) {
 94            openIntent.setType("resource/folder");
 95        } else {
 96            openIntent.setDataAndType(Uri.parse("file://" + path), "resource/folder");
 97        }
 98        openIntent.putExtra("org.openintents.extra.ABSOLUTE_PATH", path);
 99
100        final Intent amazeIntent = new Intent(Intent.ACTION_VIEW);
101        amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder");
102
103        //will open a file manager at root and user can navigate themselves
104        final Intent systemFallBack = new Intent(Intent.ACTION_VIEW);
105        systemFallBack.addCategory(Intent.CATEGORY_DEFAULT);
106        systemFallBack.setData(Uri.parse("content://com.android.externalstorage.documents/root/primary"));
107
108        return Arrays.asList(openIntent, amazeIntent, systemFallBack);
109    }
110
111    public ExportBackupService(Context context, WorkerParameters workerParams) {
112        super(context, workerParams);
113    }
114
115    @Override
116    public Result doWork() {
117        setForegroundAsync(getForegroundInfo());
118        final List<File> files;
119        try {
120            files = export();
121        } catch (final IOException | PGPException e) {
122            Log.d(Config.LOGTAG, "could not create backup", e);
123            return Result.failure();
124        } finally {
125            getApplicationContext()
126                    .getSystemService(NotificationManager.class)
127                    .cancel(NOTIFICATION_ID);
128        }
129        Log.d(Config.LOGTAG, "done creating " + files.size() + " backup files");
130        if (files.isEmpty()) {
131            return Result.success();
132        }
133        notifySuccess(files);
134        return Result.success();
135    }
136
137    @Override
138    public ForegroundInfo getForegroundInfo() {
139        Log.d(Config.LOGTAG, "getForegroundInfo()");
140        final NotificationCompat.Builder notification = getNotification();
141        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
142            return new ForegroundInfo(
143                    NOTIFICATION_ID,
144                    notification.build(),
145                    ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
146        } else {
147            return new ForegroundInfo(NOTIFICATION_ID, notification.build());
148        }
149    }
150
151    private void messageExport(SQLiteDatabase db, Account account, PrintWriter writer, Progress progress) {
152        final ImmutableMap<String,String> emptyMap = ImmutableMap.of();
153        final var mDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
154        mDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
155        final var accountJid = account.getJid().toString();
156        final var notificationManager = getApplicationContext().getSystemService(NotificationManager.class);
157        Cursor cursor = db.rawQuery("select conversations.mode, messages.type, messages.status, messages.serverMsgId, messages.timeSent, messages.counterpart, messages.trueCounterpart, messages.body, messages.subject, messages.payloads, messages.reactions, messages.occupant_id, messages.occupantId, messages.uuid, messages.remoteMsgId 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()});
158        int size = cursor != null ? cursor.getCount() : 0;
159        Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + account.getUuid());
160        int i = 0;
161        Element archive = new Element("archive", "urn:xmpp:pie:0#mam");
162        writer.write(archive.startTag().toString());
163        while (cursor != null && cursor.moveToNext()) {
164            try {
165                final var conversationMode = cursor.getInt(cursor.getColumnIndexOrThrow(Conversation.MODE));
166                final var mType = cursor.getInt(cursor.getColumnIndex(Message.TYPE));
167                final var mStatus = cursor.getInt(cursor.getColumnIndex(Message.STATUS));
168                final var serverMsgId = cursor.getString(cursor.getColumnIndex(Message.SERVER_MSG_ID));
169                final var timeSent = cursor.getLong(cursor.getColumnIndex(Message.TIME_SENT));
170                final var counterpart = cursor.getString(cursor.getColumnIndex(Message.COUNTERPART));
171                final var rawBody = cursor.getString(cursor.getColumnIndex(Message.BODY));
172                final var subject = cursor.getString(cursor.getColumnIndex("subject"));
173                final var payloads = cursor.getString(cursor.getColumnIndex("payloads"));
174
175                var result = new Element("result", "urn:xmpp:mam:2");
176                if (serverMsgId != null) result.setAttribute("id", serverMsgId);
177                var forwarded = new Element("forwarded", "urn:xmpp:forward:0");
178                final var delay = forwarded.addChild("delay", "urn:xmpp:delay");
179                final var date = new Date(timeSent);
180                delay.setAttribute("stamp", mDateFormat.format(date));
181                // TODO: time received?
182
183                var message = new Element("message", "jabber:client").setAttribute("type", conversationMode == Conversation.MODE_MULTI && mType != Message.TYPE_PRIVATE && mType != Message.TYPE_PRIVATE_FILE ? "groupchat" : "chat");
184                String outerId = null;
185                if (mStatus <= Message.STATUS_RECEIVED) {
186                    message.setAttribute("to", accountJid).setAttribute("from", counterpart);
187                    final var remoteMsgId = cursor.getString(cursor.getColumnIndex(Message.REMOTE_MSG_ID));
188                    if (remoteMsgId != null) outerId = remoteMsgId;
189                } else {
190                    message.setAttribute("from", accountJid).setAttribute("to", counterpart);
191                    outerId = cursor.getString(cursor.getColumnIndex(Message.UUID));
192                }
193                if (outerId != null) message.setAttribute("id", outerId);
194                if (rawBody != null) message.addChild(new Element("body").setContent(rawBody));
195                if (subject != null) message.addChild(new Element("subject").setContent(subject));
196                if (conversationMode == Conversation.MODE_MULTI) {
197                    final var trueCounterpart = cursor.getString(cursor.getColumnIndex(Message.TRUE_COUNTERPART));
198                    var occupantId = cursor.getString(cursor.getColumnIndexOrThrow(Message.OCCUPANT_ID));
199                    final String legacyOccupant = cursor.getString(cursor.getColumnIndex("occupant_id"));
200                    if (legacyOccupant != null) occupantId = legacyOccupant;
201                    final var x = new Element("x", "http://jabber.org/protocol/muc#user");
202                    if (trueCounterpart != null) x.addChild("item", "http://jabber.org/protocol/muc#user").setAttribute("jid", trueCounterpart);
203                    message.addChild(x);
204                    if (occupantId != null) message.addChild("occupant-id", "urn:xmpp:occupant-id:0").setAttribute("id", occupantId);
205                }
206                forwarded.addChild(message);
207                result.addChild(forwarded);
208                final StringBuilder elementOutput = new StringBuilder();
209                result.appendToBuilder(emptyMap, elementOutput, 3);
210                writer.write(elementOutput.toString());
211                if (payloads != null) writer.write(payloads);
212                writer.write("</message></forwarded></result>\n");
213                final HashMultimap<String, Reaction> aggregatedReactions = HashMultimap.create();
214                final var reactions = Reaction.fromString(cursor.getString(cursor.getColumnIndexOrThrow(Message.REACTIONS)));
215                for (final var reaction : reactions) {
216                    aggregatedReactions.put(reaction.occupantId == null ? (reaction.trueJid == null ? reaction.from.toString() : reaction.trueJid.toString()) : reaction.occupantId, reaction);
217                }
218                for (final var reactionSet : aggregatedReactions.asMap().values()) {
219                    final var reaction = reactionSet.iterator().next();
220                    result = new Element("result", "urn:xmpp:mam:2");
221                    forwarded = new Element("forwarded", "urn:xmpp:forward:0");
222                    message = new Element("message", "jabber:client").setAttribute("type", conversationMode == Conversation.MODE_MULTI && mType != Message.TYPE_PRIVATE && mType != Message.TYPE_PRIVATE_FILE ? "groupchat" : "chat");
223                    message.setAttribute("from", reaction.from).setAttribute("to", reaction.received && conversationMode != Conversation.MODE_MULTI ? accountJid : counterpart);
224                    if (reaction.envelopeId != null) message.setAttribute("id", reaction.envelopeId);
225                    final var reactionsEl = new Element("reactions", "urn:xmpp:reactions:0");
226                    reactionsEl.setAttribute("id", "groupchat".equals(message.getAttribute("type")) ? serverMsgId : outerId);
227                    for (final var r : reactionSet) {
228                        reactionsEl.addChild("reaction", "urn:xmpp:reactions:0").setContent(r.reaction);
229                    }
230                    message.addChild(reactionsEl);
231                    if (conversationMode == Conversation.MODE_MULTI) {
232                        final var x = new Element("x", "http://jabber.org/protocol/muc#user");
233                        if (reaction.trueJid != null) x.addChild("item", "http://jabber.org/protocol/muc#user").setAttribute("jid", reaction.trueJid);
234                        message.addChild(x);
235                        if (reaction.occupantId != null) message.addChild("occupant-id", "urn:xmpp:occupant-id:0").setAttribute("id", reaction.occupantId);
236                    }
237                    forwarded.addChild(message);
238                    result.addChild(forwarded);
239                    writer.write(result.toString());
240                }
241            } catch (final Exception e) {
242                Log.e(Config.LOGTAG, "message export error: " + e);
243            }
244            i++;
245            final int p = i * 100 / size;
246            notificationManager.notify(NOTIFICATION_ID, progress.build(p));
247        }
248        if (cursor != null) {
249            cursor.close();
250        }
251        messageExportCheogram(db, account, writer, progress);
252        writer.write(archive.endTag().toString());
253    }
254
255    private void messageExportCheogram(SQLiteDatabase db, Account account, PrintWriter writer, Progress progress) {
256        final var notificationManager = getApplicationContext().getSystemService(NotificationManager.class);
257        int i = 0;
258        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()});
259        int size = cursor != null ? cursor.getCount() : 0;
260        Log.d(Config.LOGTAG, "exporting " + size + " WebXDC updates for account " + account.getUuid());
261        while (cursor != null && cursor.moveToNext()) {
262            final Conversation conversation = Conversation.fromCursor(cursor);
263            Element result = new Element("result", "urn:xmpp:mam:2");
264            result.setAttribute("id", "webxdc-serial:" + cursor.getString(cursor.getColumnIndex("serial")));
265            Element forwarded = new Element("forwarded", "urn:xmpp:forward:0");
266            Element message = new Element("message", "jabber:client").setAttribute("type", conversation.getMode() == Conversation.MODE_MULTI ? "groupchat" : "chat");
267            final var sender = cursor.getString(cursor.getColumnIndex("sender"));
268            message.setAttribute("from", sender);
269            message.setAttribute("to", !account.getJid().toString().equals(sender) && conversation.getMode() != Conversation.MODE_MULTI ? account.getJid() : conversation.getJid());
270            final var info = cursor.getString(cursor.getColumnIndex("info"));
271            if (info != null) message.addChild(new Element("body").setContent(info));
272            final var thread = cursor.getString(cursor.getColumnIndex("thread"));
273            if (thread != null) {
274                final var threadParent = cursor.getString(cursor.getColumnIndex("threadParent"));
275                final var threadEl = new Element("thread").setContent(thread);
276                if (threadParent != null) threadEl.setAttribute("parent", threadParent);
277                message.addChild(threadEl);
278            }
279            final var x = new Element("x", "urn:xmpp:webxdc:0");
280            final var document = cursor.getString(cursor.getColumnIndex("document"));
281            if (document != null) x.addChild("document", "urn:xmpp:webxdc:0").setContent(document);
282            final var summary = cursor.getString(cursor.getColumnIndex("summary"));
283            if (summary != null) x.addChild("document", "urn:xmpp:webxdc:0").setContent(summary);
284            final var payload = cursor.getString(cursor.getColumnIndex("payload"));
285            if (payload != null) x.addChild("json", "urn:xmpp:json:0").setContent(payload);
286            message.addChild(x);
287            forwarded.addChild(message);
288            result.addChild(forwarded);
289            writer.write(result.toString());
290
291            i++;
292            final int p = i * 100 / size;
293            notificationManager.notify(NOTIFICATION_ID, progress.build(p));
294        }
295        if (cursor != null) {
296            cursor.close();
297        }
298    }
299
300    private List<File> export() throws IOException, PGPException {
301        final Context context = getApplicationContext();
302        final var database = DatabaseBackend.getInstance(context);
303        final var accounts = database.getAccounts();
304        final var notification = getNotification();
305        int count = 0;
306        final int max = accounts.size();
307        final List<File> files = new ArrayList<>();
308        Log.d(Config.LOGTAG, "starting backup for " + max + " accounts");
309        for (final Account account : accounts) {
310            final String password = account.getPassword();
311            if (Strings.nullToEmpty(password).trim().isEmpty()) {
312                Log.d(Config.LOGTAG, String.format("skipping backup for %s because password is empty. unable to encrypt", account.getJid().asBareJid()));
313                continue;
314            }
315            Log.d(Config.LOGTAG, String.format("exporting data for account %s (%s)", account.getJid().asBareJid(), account.getUuid()));
316            final Progress progress = new Progress(notification, max, count);
317            final File file = new File(FileBackend.getBackupDirectory(context), account.getJid().asBareJid().toString() + ".xml.pgp");
318            files.add(file);
319            final File directory = file.getParentFile();
320            if (directory != null && directory.mkdirs()) {
321                Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
322            }
323            final FileOutputStream fileOutputStream = new FileOutputStream(file);
324
325            PGPLiteralDataGenerator lData = new PGPLiteralDataGenerator();
326            PGPCompressedDataGenerator comData = new PGPCompressedDataGenerator(PGPCompressedDataGenerator.ZLIB);
327            PGPEncryptedDataGenerator encGen = new PGPEncryptedDataGenerator(
328                new JcePGPDataEncryptorBuilder(PGPEncryptedDataGenerator.AES_256).setUseV6AEAD().setWithAEAD(PGPEncryptedData.GCM, 16)
329            );
330            encGen.setForceSessionKey(true);
331            encGen.addMethod(new BcPBEKeyEncryptionMethodGenerator(
332                password.toCharArray(),
333                Argon2Params.memoryConstrainedParameters()
334            ).setSecureRandom(new SecureRandom()));
335
336            PrintWriter writer = new PrintWriter(lData.open(
337                comData.open(
338                    encGen.open(fileOutputStream, new byte[4096]),
339                    new byte[4096]
340                ),
341                PGPLiteralDataGenerator.UTF8,
342                account.getJid().asBareJid().toString() + ".xml",
343                PGPLiteralDataGenerator.NOW,
344                new byte[4096]
345            ));
346
347            Element serverData = new Element("server-data", "urn:xmpp:pie:0");
348            Element host = new Element("host").setAttribute("jid", account.getDomain());
349            Element user = new Element("user").setAttribute("name", account.getUsername());
350
351            writer.write(serverData.startTag().toString());
352            writer.write(host.startTag().toString());
353            writer.write(user.startTag().toString());
354            writer.write("\n");
355
356            Element roster = new Element("query", "jabber:iq:roster");
357            if (!"".equals(account.getRosterVersion())) roster.setAttribute("ver", account.getRosterVersion());
358            // TODO: conversations, contacts, bookmarks?
359            writer.write(roster.toString());
360
361            if (account.getDisplayName() != null && !"".equals(account.getDisplayName())) {
362                Element nickname = new Element("pubsub", "http://jabber.org/protocol/pubsub#owner");
363                Element nickItems = new Element("items").setAttribute("node", "http://jabber.org/protocol/nick");
364                Element nickItem = new Element("item");
365                nickItem.addChild(new Element("nick", "http://jabber.org/protocol/nick").setContent(account.getDisplayName()));
366                nickItems.addChild(nickItem);
367                nickname.addChild(nickItems);
368                writer.write(nickname.toString());
369            }
370
371            if (account.getAvatar() != null) {
372                Element avatar = new Element("pubsub", "http://jabber.org/protocol/pubsub#owner");
373                Element avatarItems = new Element("items").setAttribute("node", "urn:xmpp:avatar:metadata");
374                Element avatarItem = new Element("item").setAttribute("id", account.getAvatar());
375                Element avatarMeta = new Element("metadata", "urn:xmpp:avatar:metadata");
376                avatarMeta.addChild(new Element("info").setAttribute("id", account.getAvatar()));
377                avatarItem.addChild(avatarMeta);
378                avatarItems.addChild(avatarItem);
379                avatar.addChild(avatarItems);
380                writer.write(avatar.toString());
381                final var f = new File(context.getCacheDir(), "/avatars/" + account.getAvatar());
382                if (f.canRead()) {
383                    final var byteArrayOutputStream = new ByteArrayOutputStream();
384                    final var base64OutputStream = new Base64OutputStream(byteArrayOutputStream, Base64.DEFAULT);
385                    ByteStreams.copy(new FileInputStream(f), base64OutputStream);
386                    base64OutputStream.flush();
387                    base64OutputStream.close();
388
389                    Element avatar2 = new Element("pubsub", "http://jabber.org/protocol/pubsub#owner");
390                    Element avatarItems2 = new Element("items").setAttribute("node", "urn:xmpp:avatar:data");
391                    Element avatarItem2 = new Element("item").setAttribute("id", account.getAvatar());
392                    Element avatarData = new Element("data", "urn:xmpp:avatar:data");
393                    avatarData.setContent(new String(byteArrayOutputStream.toByteArray()));
394                    avatarItem2.addChild(avatarData);
395                    avatarItems2.addChild(avatarItem2);
396                    avatar2.addChild(avatarItems2);
397                    writer.write(avatar2.toString());
398                }
399            }
400
401            SQLiteDatabase db = database.getReadableDatabase();
402            final String uuid = account.getUuid();
403            messageExport(db, account, writer, progress);
404
405            writer.write("\n");
406            writer.write(user.endTag().toString());
407            writer.write(host.endTag().toString());
408            writer.write(serverData.endTag().toString());
409
410            writer.flush();
411            writer.close();
412            lData.close();
413            comData.close();
414            encGen.close();
415            fileOutputStream.flush();
416            fileOutputStream.close();
417            Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
418            count++;
419        }
420        return files;
421    }
422
423    private void notifySuccess(final List<File> files) {
424        final var context = getApplicationContext();
425        final String path = FileBackend.getBackupDirectory(context).getAbsolutePath();
426
427        final var openFolderIntent = getOpenFolderIntent(path);
428
429        final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
430        final ArrayList<Uri> uris = new ArrayList<>();
431        for (final File file : files) {
432            uris.add(FileBackend.getUriForFile(context, file, file.getName()));
433        }
434        intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
435        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
436        intent.setType("application/pgp-encrypted");
437        final Intent chooser =
438                Intent.createChooser(intent, context.getString(R.string.share_backup_files));
439        final var shareFilesIntent =
440                PendingIntent.getActivity(context, 190, chooser, PENDING_INTENT_FLAGS);
441
442        NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, "backup");
443        mBuilder.setContentTitle(context.getString(R.string.notification_backup_created_title))
444                .setContentText(
445                        context.getString(R.string.notification_backup_created_subtitle, path))
446                .setStyle(
447                        new NotificationCompat.BigTextStyle()
448                                .bigText(
449                                        context.getString(
450                                                R.string.notification_backup_created_subtitle,
451                                                FileBackend.getBackupDirectory(context)
452                                                        .getAbsolutePath())))
453                .setAutoCancel(true)
454                .setSmallIcon(R.drawable.ic_archive_24dp);
455
456        if (openFolderIntent.isPresent()) {
457            mBuilder.setContentIntent(openFolderIntent.get());
458        } else {
459            Log.w(Config.LOGTAG, "no app can display folders");
460        }
461
462        mBuilder.addAction(
463                R.drawable.ic_share_24dp,
464                context.getString(R.string.share_backup_files),
465                shareFilesIntent);
466        final var notificationManager = context.getSystemService(NotificationManager.class);
467        notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, mBuilder.build());
468    }
469
470    private Optional<PendingIntent> getOpenFolderIntent(final String path) {
471        final var context = getApplicationContext();
472        for (final Intent intent : getPossibleFileOpenIntents(context, path)) {
473            if (intent.resolveActivityInfo(context.getPackageManager(), 0) != null) {
474                return Optional.of(
475                        PendingIntent.getActivity(context, 189, intent, PENDING_INTENT_FLAGS));
476            }
477        }
478        return Optional.absent();
479    }
480
481    private NotificationCompat.Builder getNotification() {
482        final var context = getApplicationContext();
483        final NotificationCompat.Builder notification =
484                new NotificationCompat.Builder(context, "backup");
485        notification
486                .setContentTitle(context.getString(R.string.notification_create_backup_title))
487                .setSmallIcon(R.drawable.ic_archive_24dp)
488                .setProgress(1, 0, false);
489        notification.setOngoing(true);
490        notification.setLocalOnly(true);
491        return notification;
492    }
493
494    private static class Progress {
495        private final NotificationCompat.Builder builder;
496        private final int max;
497        private final int count;
498
499        private Progress(NotificationCompat.Builder builder, int max, int count) {
500            this.builder = builder;
501            this.max = max;
502            this.count = count;
503        }
504
505        private Notification build(int percentage) {
506            builder.setProgress(max * 100, count * 100 + percentage, false);
507            return builder.build();
508        }
509    }
510}