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