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