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}