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