ExportBackupService.java

  1package eu.siacs.conversations.services;
  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.database.Cursor;
 12import android.database.DatabaseUtils;
 13import android.database.sqlite.SQLiteDatabase;
 14import android.net.Uri;
 15import android.os.IBinder;
 16import android.util.Log;
 17
 18import androidx.core.app.NotificationCompat;
 19
 20import com.google.common.base.CharMatcher;
 21import com.google.common.base.Strings;
 22import com.google.gson.stream.JsonWriter;
 23
 24import java.io.DataOutputStream;
 25import java.io.File;
 26import java.io.FileOutputStream;
 27import java.io.IOException;
 28import java.io.OutputStreamWriter;
 29import java.io.PrintWriter;
 30import java.nio.charset.StandardCharsets;
 31import java.security.NoSuchAlgorithmException;
 32import java.security.SecureRandom;
 33import java.security.spec.InvalidKeySpecException;
 34import java.text.SimpleDateFormat;
 35import java.util.ArrayList;
 36import java.util.Arrays;
 37import java.util.Collections;
 38import java.util.Date;
 39import java.util.List;
 40import java.util.Locale;
 41import java.util.concurrent.atomic.AtomicBoolean;
 42import java.util.zip.GZIPOutputStream;
 43
 44import javax.crypto.Cipher;
 45import javax.crypto.CipherOutputStream;
 46import javax.crypto.SecretKeyFactory;
 47import javax.crypto.spec.IvParameterSpec;
 48import javax.crypto.spec.PBEKeySpec;
 49import javax.crypto.spec.SecretKeySpec;
 50
 51import eu.siacs.conversations.Config;
 52import eu.siacs.conversations.R;
 53import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
 54import eu.siacs.conversations.entities.Account;
 55import eu.siacs.conversations.entities.Conversation;
 56import eu.siacs.conversations.entities.Message;
 57import eu.siacs.conversations.persistance.DatabaseBackend;
 58import eu.siacs.conversations.persistance.FileBackend;
 59import eu.siacs.conversations.utils.BackupFileHeader;
 60import eu.siacs.conversations.utils.Compatibility;
 61
 62public class ExportBackupService extends Service {
 63
 64    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.US);
 65
 66    public static final String KEYTYPE = "AES";
 67    public static final String CIPHERMODE = "AES/GCM/NoPadding";
 68    public static final String PROVIDER = "BC";
 69
 70    public static final String MIME_TYPE = "application/vnd.conversations.backup";
 71
 72    private static final int NOTIFICATION_ID = 19;
 73    private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
 74    private DatabaseBackend mDatabaseBackend;
 75    private List<Account> mAccounts;
 76    private NotificationManager notificationManager;
 77
 78    private static List<Intent> getPossibleFileOpenIntents(
 79            final Context context, final String path) {
 80
 81        // http://www.openintents.org/action/android-intent-action-view/file-directory
 82        // do not use 'vnd.android.document/directory' since this will trigger system file manager
 83        final Intent openIntent = new Intent(Intent.ACTION_VIEW);
 84        openIntent.addCategory(Intent.CATEGORY_DEFAULT);
 85        if (Compatibility.runsAndTargetsTwentyFour(context)) {
 86            openIntent.setType("resource/folder");
 87        } else {
 88            openIntent.setDataAndType(Uri.parse("file://" + path), "resource/folder");
 89        }
 90        openIntent.putExtra("org.openintents.extra.ABSOLUTE_PATH", path);
 91
 92        final Intent amazeIntent = new Intent(Intent.ACTION_VIEW);
 93        amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder");
 94
 95        // will open a file manager at root and user can navigate themselves
 96        final Intent systemFallBack = new Intent(Intent.ACTION_VIEW);
 97        systemFallBack.addCategory(Intent.CATEGORY_DEFAULT);
 98        systemFallBack.setData(
 99                Uri.parse("content://com.android.externalstorage.documents/root/primary"));
100
101        return Arrays.asList(openIntent, amazeIntent, systemFallBack);
102    }
103
104    private static void accountExport(
105            final SQLiteDatabase db, final String uuid, final JsonWriter writer)
106            throws IOException {
107        final Cursor accountCursor =
108                db.query(
109                        Account.TABLENAME,
110                        null,
111                        Account.UUID + "=?",
112                        new String[] {uuid},
113                        null,
114                        null,
115                        null);
116        while (accountCursor != null && accountCursor.moveToNext()) {
117            writer.beginObject();
118            writer.name("table");
119            writer.value(Account.TABLENAME);
120            writer.name("values");
121            writer.beginObject();
122            for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
123                final String name = accountCursor.getColumnName(i);
124                writer.name(name);
125                final String value = accountCursor.getString(i);
126                if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
127                    writer.nullValue();
128                } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i))
129                        && value.matches("\\d+")) {
130                    int intValue = Integer.parseInt(value);
131                    intValue |= 1 << Account.OPTION_DISABLED;
132                    writer.value(intValue);
133                } else {
134                    writer.value(value);
135                }
136            }
137            writer.endObject();
138            writer.endObject();
139        }
140        if (accountCursor != null) {
141            accountCursor.close();
142        }
143    }
144
145    private static void simpleExport(
146            final SQLiteDatabase db,
147            final String table,
148            final String column,
149            final String uuid,
150            final JsonWriter writer)
151            throws IOException {
152        final Cursor cursor =
153                db.query(table, null, column + "=?", new String[] {uuid}, null, null, null);
154        while (cursor != null && cursor.moveToNext()) {
155            writer.beginObject();
156            writer.name("table");
157            writer.value(table);
158            writer.name("values");
159            writer.beginObject();
160            for (int i = 0; i < cursor.getColumnCount(); ++i) {
161                final String name = cursor.getColumnName(i);
162                writer.name(name);
163                final String value = cursor.getString(i);
164                writer.value(value);
165            }
166            writer.endObject();
167            writer.endObject();
168        }
169        if (cursor != null) {
170            cursor.close();
171        }
172    }
173
174    public static byte[] getKey(final String password, final byte[] salt)
175            throws InvalidKeySpecException {
176        final SecretKeyFactory factory;
177        try {
178            factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
179        } catch (NoSuchAlgorithmException e) {
180            throw new IllegalStateException(e);
181        }
182        return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128))
183                .getEncoded();
184    }
185
186    @Override
187    public void onCreate() {
188        mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
189        mAccounts = mDatabaseBackend.getAccounts();
190        notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
191    }
192
193    @Override
194    public int onStartCommand(Intent intent, int flags, int startId) {
195        if (RUNNING.compareAndSet(false, true)) {
196            new Thread(
197                            () -> {
198                                boolean success;
199                                List<File> files;
200                                try {
201                                    files = export();
202                                    success = true;
203                                } catch (final Exception e) {
204                                    Log.d(Config.LOGTAG, "unable to create backup", e);
205                                    success = false;
206                                    files = Collections.emptyList();
207                                }
208                                stopForeground(true);
209                                RUNNING.set(false);
210                                if (success) {
211                                    notifySuccess(files);
212                                }
213                                stopSelf();
214                            })
215                    .start();
216            return START_STICKY;
217        } else {
218            Log.d(
219                    Config.LOGTAG,
220                    "ExportBackupService. ignoring start command because already running");
221        }
222        return START_NOT_STICKY;
223    }
224
225    private void messageExport(
226            final SQLiteDatabase db,
227            final String uuid,
228            final JsonWriter writer,
229            final Progress progress)
230            throws IOException {
231        Cursor cursor =
232                db.rawQuery(
233                        "select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?",
234                        new String[] {uuid});
235        int size = cursor != null ? cursor.getCount() : 0;
236        Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid);
237        int i = 0;
238        int p = 0;
239        while (cursor != null && cursor.moveToNext()) {
240            writer.beginObject();
241            writer.name("table");
242            writer.value(Message.TABLENAME);
243            writer.name("values");
244            writer.beginObject();
245            for (int j = 0; j < cursor.getColumnCount(); ++j) {
246                final String name = cursor.getColumnName(j);
247                writer.name(name);
248                final String value = cursor.getString(j);
249                writer.value(value);
250            }
251            writer.endObject();
252            writer.endObject();
253            final int percentage = i * 100 / size;
254            if (p < percentage) {
255                p = percentage;
256                notificationManager.notify(NOTIFICATION_ID, progress.build(p));
257            }
258            i++;
259        }
260        if (cursor != null) {
261            cursor.close();
262        }
263    }
264
265    private List<File> export() throws Exception {
266        NotificationCompat.Builder mBuilder =
267                new NotificationCompat.Builder(getBaseContext(), "backup");
268        mBuilder.setContentTitle(getString(R.string.notification_create_backup_title))
269                .setSmallIcon(R.drawable.ic_archive_white_24dp)
270                .setProgress(1, 0, false);
271        startForeground(NOTIFICATION_ID, mBuilder.build());
272        int count = 0;
273        final int max = this.mAccounts.size();
274        final SecureRandom secureRandom = new SecureRandom();
275        final List<File> files = new ArrayList<>();
276        Log.d(Config.LOGTAG, "starting backup for " + max + " accounts");
277        for (final Account account : this.mAccounts) {
278            final String password = account.getPassword();
279            if (Strings.nullToEmpty(password).trim().isEmpty()) {
280                Log.d(
281                        Config.LOGTAG,
282                        String.format(
283                                "skipping backup for %s because password is empty. unable to encrypt",
284                                account.getJid().asBareJid()));
285                continue;
286            }
287            Log.d(
288                    Config.LOGTAG,
289                    String.format(
290                            "exporting data for account %s (%s)",
291                            account.getJid().asBareJid(), account.getUuid()));
292            final byte[] IV = new byte[12];
293            final byte[] salt = new byte[16];
294            secureRandom.nextBytes(IV);
295            secureRandom.nextBytes(salt);
296            final BackupFileHeader backupFileHeader =
297                    new BackupFileHeader(
298                            getString(R.string.app_name),
299                            account.getJid(),
300                            System.currentTimeMillis(),
301                            IV,
302                            salt);
303            final Progress progress = new Progress(mBuilder, max, count);
304            final String filename =
305                    String.format(
306                            "%s.%s.ceb",
307                            account.getJid().asBareJid().toEscapedString(),
308                            DATE_FORMAT.format(new Date()));
309            final File file =
310                    new File(
311                            FileBackend.getBackupDirectory(this), filename);
312            files.add(file);
313            final File directory = file.getParentFile();
314            if (directory != null && directory.mkdirs()) {
315                Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
316            }
317            final FileOutputStream fileOutputStream = new FileOutputStream(file);
318            final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
319            backupFileHeader.write(dataOutputStream);
320            dataOutputStream.flush();
321
322            final Cipher cipher =
323                    Compatibility.twentyEight()
324                            ? Cipher.getInstance(CIPHERMODE)
325                            : Cipher.getInstance(CIPHERMODE, PROVIDER);
326            final byte[] key = getKey(password, salt);
327            SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
328            IvParameterSpec ivSpec = new IvParameterSpec(IV);
329            cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
330            CipherOutputStream cipherOutputStream =
331                    new CipherOutputStream(fileOutputStream, cipher);
332
333            final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
334            final JsonWriter jsonWriter =
335                    new JsonWriter(
336                            new OutputStreamWriter(gzipOutputStream, StandardCharsets.UTF_8));
337            jsonWriter.beginArray();
338            final SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase();
339            final String uuid = account.getUuid();
340            accountExport(db, uuid, jsonWriter);
341            simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, jsonWriter);
342            messageExport(db, uuid, jsonWriter, progress);
343            for (final String table :
344                    Arrays.asList(
345                            SQLiteAxolotlStore.PREKEY_TABLENAME,
346                            SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
347                            SQLiteAxolotlStore.SESSION_TABLENAME,
348                            SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
349                simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, jsonWriter);
350            }
351            jsonWriter.endArray();
352            jsonWriter.flush();
353            jsonWriter.close();
354            mediaScannerScanFile(file);
355            Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
356            count++;
357        }
358        return files;
359    }
360
361    private void mediaScannerScanFile(final File file) {
362        final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
363        intent.setData(Uri.fromFile(file));
364        sendBroadcast(intent);
365    }
366
367    private void notifySuccess(final List<File> files) {
368        final String path = FileBackend.getBackupDirectory(this).getAbsolutePath();
369
370        PendingIntent openFolderIntent = null;
371
372        for (final Intent intent : getPossibleFileOpenIntents(this, path)) {
373            if (intent.resolveActivityInfo(getPackageManager(), 0) != null) {
374                openFolderIntent =
375                        PendingIntent.getActivity(
376                                this,
377                                189,
378                                intent,
379                                s()
380                                        ? PendingIntent.FLAG_IMMUTABLE
381                                                | PendingIntent.FLAG_UPDATE_CURRENT
382                                        : PendingIntent.FLAG_UPDATE_CURRENT);
383                break;
384            }
385        }
386
387        PendingIntent shareFilesIntent = null;
388        if (files.size() > 0) {
389            final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
390            ArrayList<Uri> uris = new ArrayList<>();
391            for (File file : files) {
392                uris.add(FileBackend.getUriForFile(this, file));
393            }
394            intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
395            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
396            intent.setType(MIME_TYPE);
397            final Intent chooser =
398                    Intent.createChooser(intent, getString(R.string.share_backup_files));
399            shareFilesIntent =
400                    PendingIntent.getActivity(
401                            this,
402                            190,
403                            chooser,
404                            s()
405                                    ? PendingIntent.FLAG_IMMUTABLE
406                                            | PendingIntent.FLAG_UPDATE_CURRENT
407                                    : PendingIntent.FLAG_UPDATE_CURRENT);
408        }
409
410        NotificationCompat.Builder mBuilder =
411                new NotificationCompat.Builder(getBaseContext(), "backup");
412        mBuilder.setContentTitle(getString(R.string.notification_backup_created_title))
413                .setContentText(getString(R.string.notification_backup_created_subtitle, path))
414                .setStyle(
415                        new NotificationCompat.BigTextStyle()
416                                .bigText(
417                                        getString(
418                                                R.string.notification_backup_created_subtitle,
419                                                FileBackend.getBackupDirectory(this)
420                                                        .getAbsolutePath())))
421                .setAutoCancel(true)
422                .setContentIntent(openFolderIntent)
423                .setSmallIcon(R.drawable.ic_archive_white_24dp);
424
425        if (shareFilesIntent != null) {
426            mBuilder.addAction(
427                    R.drawable.ic_share_white_24dp,
428                    getString(R.string.share_backup_files),
429                    shareFilesIntent);
430        }
431
432        notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
433    }
434
435    @Override
436    public IBinder onBind(Intent intent) {
437        return null;
438    }
439
440    private static class Progress {
441        private final NotificationCompat.Builder builder;
442        private final int max;
443        private final int count;
444
445        private Progress(NotificationCompat.Builder builder, int max, int count) {
446            this.builder = builder;
447            this.max = max;
448            this.count = count;
449        }
450
451        private Notification build(int percentage) {
452            builder.setProgress(max * 100, count * 100 + percentage, false);
453            return builder.build();
454        }
455    }
456}