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