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