ExportBackupService.java

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