ExportBackupService.java

  1package eu.siacs.conversations.services;
  2
  3import android.app.Notification;
  4import android.app.NotificationManager;
  5import android.app.Service;
  6import android.content.Context;
  7import android.content.Intent;
  8import android.database.Cursor;
  9import android.database.DatabaseUtils;
 10import android.database.sqlite.SQLiteDatabase;
 11import android.os.IBinder;
 12import android.support.v4.app.NotificationCompat;
 13import android.util.Log;
 14
 15import java.io.DataOutputStream;
 16import java.io.File;
 17import java.io.FileOutputStream;
 18import java.io.PrintWriter;
 19import java.security.NoSuchAlgorithmException;
 20import java.security.SecureRandom;
 21import java.security.spec.InvalidKeySpecException;
 22import java.util.Arrays;
 23import java.util.List;
 24import java.util.concurrent.atomic.AtomicBoolean;
 25import java.util.zip.GZIPOutputStream;
 26
 27import javax.crypto.Cipher;
 28import javax.crypto.CipherOutputStream;
 29import javax.crypto.SecretKeyFactory;
 30import javax.crypto.spec.IvParameterSpec;
 31import javax.crypto.spec.PBEKeySpec;
 32import javax.crypto.spec.SecretKeySpec;
 33
 34import eu.siacs.conversations.Config;
 35import eu.siacs.conversations.R;
 36import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
 37import eu.siacs.conversations.entities.Account;
 38import eu.siacs.conversations.entities.Conversation;
 39import eu.siacs.conversations.entities.Message;
 40import eu.siacs.conversations.persistance.DatabaseBackend;
 41import eu.siacs.conversations.persistance.FileBackend;
 42import eu.siacs.conversations.utils.BackupFileHeader;
 43import eu.siacs.conversations.utils.Compatibility;
 44
 45public class ExportBackupService extends Service {
 46
 47    public static final String KEYTYPE = "AES";
 48    public static final String CIPHERMODE = "AES/GCM/NoPadding";
 49    public static final String PROVIDER = "BC";
 50
 51    private static final int NOTIFICATION_ID = 19;
 52    private static AtomicBoolean running = new AtomicBoolean(false);
 53    private DatabaseBackend mDatabaseBackend;
 54    private List<Account> mAccounts;
 55    private NotificationManager notificationManager;
 56
 57    @Override
 58    public void onCreate() {
 59        mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
 60        mAccounts = mDatabaseBackend.getAccounts();
 61        notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
 62    }
 63
 64    @Override
 65    public int onStartCommand(Intent intent, int flags, int startId) {
 66        if (running.compareAndSet(false, true)) {
 67            new Thread(() -> {
 68                export();
 69                stopForeground(true);
 70                running.set(false);
 71                stopSelf();
 72            }).start();
 73        }
 74        return START_NOT_STICKY;
 75    }
 76
 77    private static void accountExport(SQLiteDatabase db, String uuid, PrintWriter writer) {
 78        StringBuilder builder = new StringBuilder();
 79        final Cursor accountCursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", new String[]{uuid}, null, null, null);
 80        while (accountCursor != null && accountCursor.moveToNext()) {
 81            builder.append("INSERT INTO ").append(Account.TABLENAME).append("(");
 82            for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
 83                if (i != 0) {
 84                    builder.append(',');
 85                }
 86                builder.append(accountCursor.getColumnName(i));
 87            }
 88            builder.append(") VALUES(");
 89            for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
 90                if (i != 0) {
 91                    builder.append(',');
 92                }
 93                final String value = accountCursor.getString(i);
 94                if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
 95                    builder.append("NULL");
 96                } else if (value.matches("\\d+")) {
 97                    int intValue = Integer.parseInt(value);
 98                    Log.d(Config.LOGTAG,"reading int value. "+intValue);
 99                    if (Account.OPTIONS.equals(accountCursor.getColumnName(i))) {
100                        intValue |= 1 << Account.OPTION_DISABLED;
101                        Log.d(Config.LOGTAG,"modified int value "+intValue);
102                    }
103                    builder.append(intValue);
104                } else {
105                    DatabaseUtils.appendEscapedSQLString(builder, value);
106                }
107            }
108            builder.append(")");
109            builder.append(';');
110            builder.append('\n');
111        }
112        Log.d(Config.LOGTAG,builder.toString());
113        if (accountCursor != null) {
114            accountCursor.close();
115        }
116        writer.append(builder.toString());
117    }
118
119    private void messageExport(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) {
120        Cursor cursor = db.rawQuery("select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", new String[]{uuid});
121        int size = cursor != null ? cursor.getCount() : 0;
122        Log.d(Config.LOGTAG, "exporting " + size + " messages");
123        int i = 0;
124        int p = 0;
125        while (cursor != null && cursor.moveToNext()) {
126            writer.write(cursorToString(Message.TABLENAME, cursor, 20));
127            if (i + 20 > size) {
128                i = size;
129            } else {
130                i += 20;
131            }
132            final int percentage = i * 100 / size;
133            if (p < percentage) {
134                p = percentage;
135                notificationManager.notify(NOTIFICATION_ID,progress.build(p));
136                Log.d(Config.LOGTAG, "percentage=" + p);
137            }
138        }
139        if (cursor != null) {
140            cursor.close();
141        }
142    }
143
144    private static void simpleExport(SQLiteDatabase db, String table, String column, String uuid, PrintWriter writer) {
145        final Cursor cursor = db.query(table, null, column + "=?", new String[]{uuid}, null, null, null);
146        while (cursor != null && cursor.moveToNext()) {
147            writer.write(cursorToString(table, cursor, 20));
148        }
149        if (cursor != null) {
150            cursor.close();
151        }
152    }
153
154    private void export() {
155        NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
156        mBuilder.setContentTitle(getString(R.string.notification_create_backup_title))
157                .setSmallIcon(R.drawable.ic_archive_white_24dp)
158                .setProgress(1, 0, false);
159        startForeground(NOTIFICATION_ID, mBuilder.build());
160        try {
161            int count = 0;
162            final int max = this.mAccounts.size();
163            final SecureRandom secureRandom = new SecureRandom();
164            for (Account account : this.mAccounts) {
165                final byte[] IV = new byte[12];
166                final byte[] salt = new byte[16];
167                secureRandom.nextBytes(IV);
168                secureRandom.nextBytes(salt);
169                final BackupFileHeader backupFileHeader = new BackupFileHeader(getString(R.string.app_name),account.getJid(),System.currentTimeMillis(),IV,salt);
170                final Progress progress = new Progress(mBuilder, max, count);
171                final File file = new File(FileBackend.getBackupDirectory(this)+account.getJid().asBareJid().toEscapedString()+".ceb");
172                if (file.getParentFile().mkdirs()) {
173                    Log.d(Config.LOGTAG,"created backup directory "+file.getParentFile().getAbsolutePath());
174                }
175                final FileOutputStream fileOutputStream = new FileOutputStream(file);
176                final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
177                backupFileHeader.write(dataOutputStream);
178                dataOutputStream.flush();
179
180                final Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER);
181                byte[] key = getKey(account.getPassword(), salt);
182                Log.d(Config.LOGTAG,backupFileHeader.toString());
183                SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
184                IvParameterSpec ivSpec = new IvParameterSpec(IV);
185                cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
186                CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher);
187
188                GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
189                PrintWriter writer = new PrintWriter(gzipOutputStream);
190                SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase();
191                final String uuid = account.getUuid();
192                accountExport(db, uuid, writer);
193                simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, writer);
194                messageExport(db, uuid, writer, progress);
195                for(String table : Arrays.asList(SQLiteAxolotlStore.PREKEY_TABLENAME, SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME, SQLiteAxolotlStore.SESSION_TABLENAME, SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
196                    simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT,uuid,writer);
197                }
198                writer.flush();
199                writer.close();
200                Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
201                count++;
202            }
203        } catch (Exception e) {
204            Log.d(Config.LOGTAG, "unable to create backup ", e);
205        }
206    }
207
208    public static byte[] getKey(String password, byte[] salt) {
209        try {
210            SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
211            return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)).getEncoded();
212        } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
213            throw new AssertionError(e);
214        }
215    }
216
217    private static String cursorToString(String tablename, Cursor cursor, int max) {
218        StringBuilder builder = new StringBuilder();
219        builder.append("INSERT INTO ").append(tablename).append("(");
220        for (int i = 0; i < cursor.getColumnCount(); ++i) {
221            if (i != 0) {
222                builder.append(',');
223            }
224            builder.append(cursor.getColumnName(i));
225        }
226        builder.append(") VALUES");
227        for (int i = 0; i < max; ++i) {
228            if (i != 0) {
229                builder.append(',');
230            }
231            appendValues(cursor, builder);
232            if (!cursor.moveToNext()) {
233                break;
234            }
235        }
236        builder.append(';');
237        builder.append('\n');
238        return builder.toString();
239    }
240
241    private static void appendValues(Cursor cursor, StringBuilder builder) {
242        builder.append("(");
243        for (int i = 0; i < cursor.getColumnCount(); ++i) {
244            if (i != 0) {
245                builder.append(',');
246            }
247            final String value = cursor.getString(i);
248            if (value == null) {
249                builder.append("NULL");
250            } else if (value.matches("\\d+")) {
251                builder.append(value);
252            } else {
253                DatabaseUtils.appendEscapedSQLString(builder, value);
254            }
255        }
256        builder.append(")");
257
258    }
259
260    @Override
261    public IBinder onBind(Intent intent) {
262        return null;
263    }
264
265    private class Progress {
266        private final NotificationCompat.Builder builder;
267        private final int max;
268        private final int count;
269
270        private Progress(NotificationCompat.Builder builder, int max, int count) {
271            this.builder = builder;
272            this.max = max;
273            this.count = count;
274        }
275
276        private Notification build(int percentage) {
277            builder.setProgress(max * 100,count * 100 + percentage,false);
278            return builder.build();
279        }
280    }
281}