ImportBackupService.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.sqlite.SQLiteDatabase;
 13import android.net.Uri;
 14import android.os.Binder;
 15import android.os.IBinder;
 16import android.provider.OpenableColumns;
 17import android.util.Log;
 18
 19import androidx.core.app.NotificationCompat;
 20import androidx.core.app.NotificationManagerCompat;
 21
 22import com.google.common.base.Charsets;
 23import com.google.common.base.Stopwatch;
 24import com.google.common.io.CountingInputStream;
 25
 26import org.bouncycastle.crypto.engines.AESEngine;
 27import org.bouncycastle.crypto.io.CipherInputStream;
 28import org.bouncycastle.crypto.modes.AEADBlockCipher;
 29import org.bouncycastle.crypto.modes.GCMBlockCipher;
 30import org.bouncycastle.crypto.params.AEADParameters;
 31import org.bouncycastle.crypto.params.KeyParameter;
 32
 33import java.io.BufferedReader;
 34import java.io.DataInputStream;
 35import java.io.File;
 36import java.io.FileInputStream;
 37import java.io.FileNotFoundException;
 38import java.io.IOException;
 39import java.io.InputStream;
 40import java.io.InputStreamReader;
 41import java.util.ArrayList;
 42import java.util.Arrays;
 43import java.util.Collections;
 44import java.util.HashSet;
 45import java.util.List;
 46import java.util.Set;
 47import java.util.WeakHashMap;
 48import java.util.concurrent.atomic.AtomicBoolean;
 49import java.util.zip.GZIPInputStream;
 50import java.util.zip.ZipException;
 51
 52import javax.crypto.BadPaddingException;
 53
 54import eu.siacs.conversations.Config;
 55import eu.siacs.conversations.R;
 56import eu.siacs.conversations.persistance.DatabaseBackend;
 57import eu.siacs.conversations.persistance.FileBackend;
 58import eu.siacs.conversations.ui.ManageAccountActivity;
 59import eu.siacs.conversations.utils.BackupFileHeader;
 60import eu.siacs.conversations.utils.SerialSingleThreadExecutor;
 61import eu.siacs.conversations.worker.ExportBackupWorker;
 62import eu.siacs.conversations.xmpp.Jid;
 63
 64public class ImportBackupService extends Service {
 65
 66    private static final int NOTIFICATION_ID = 21;
 67    private static final AtomicBoolean running = new AtomicBoolean(false);
 68    private final ImportBackupServiceBinder binder = new ImportBackupServiceBinder();
 69    private final SerialSingleThreadExecutor executor = new SerialSingleThreadExecutor(getClass().getSimpleName());
 70    private final Set<OnBackupProcessed> mOnBackupProcessedListeners = Collections.newSetFromMap(new WeakHashMap<>());
 71    private DatabaseBackend mDatabaseBackend;
 72    private NotificationManager notificationManager;
 73
 74    private static int count(String input, char c) {
 75        int count = 0;
 76        for (char aChar : input.toCharArray()) {
 77            if (aChar == c) {
 78                ++count;
 79            }
 80        }
 81        return count;
 82    }
 83
 84    @Override
 85    public void onCreate() {
 86        mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
 87        notificationManager = (android.app.NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
 88    }
 89
 90    @Override
 91    public int onStartCommand(Intent intent, int flags, int startId) {
 92        if (intent == null) {
 93            return START_NOT_STICKY;
 94        }
 95        final String password = intent.getStringExtra("password");
 96        final Uri data = intent.getData();
 97        final Uri uri;
 98        if (data == null) {
 99            final String file = intent.getStringExtra("file");
100            uri = file == null ? null : Uri.fromFile(new File(file));
101        } else {
102            uri = data;
103        }
104
105        if (password == null || password.isEmpty() || uri == null) {
106            return START_NOT_STICKY;
107        }
108        if (running.compareAndSet(false, true)) {
109            executor.execute(() -> {
110                startForegroundService();
111                final boolean success = importBackup(uri, password);
112                stopForeground(true);
113                running.set(false);
114                if (success) {
115                    notifySuccess();
116                }
117                stopSelf();
118            });
119        } else {
120            Log.d(Config.LOGTAG, "backup already running");
121        }
122        return START_NOT_STICKY;
123    }
124
125    public boolean getLoadingState() {
126        return running.get();
127    }
128
129    public void loadBackupFiles(final OnBackupFilesLoaded onBackupFilesLoaded) {
130        executor.execute(() -> {
131            final List<Jid> accounts = mDatabaseBackend.getAccountJids(false);
132            final ArrayList<BackupFile> backupFiles = new ArrayList<>();
133            final Set<String> apps = new HashSet<>(Arrays.asList("Conversations", "Quicksy", getString(R.string.app_name)));
134            final List<File> directories = new ArrayList<>();
135            for (final String app : apps) {
136                directories.add(FileBackend.getLegacyBackupDirectory(app));
137            }
138            directories.add(FileBackend.getBackupDirectory(this));
139            for (final File directory : directories) {
140                if (!directory.exists() || !directory.isDirectory()) {
141                    Log.d(Config.LOGTAG, "directory not found: " + directory.getAbsolutePath());
142                    continue;
143                }
144                final File[] files = directory.listFiles();
145                if (files == null) {
146                    continue;
147                }
148                for (final File file : files) {
149                    if (file.isFile() && file.getName().endsWith(".ceb")) {
150                        try {
151                            final BackupFile backupFile = BackupFile.read(file);
152                            if (accounts.contains(backupFile.getHeader().getJid())) {
153                                Log.d(Config.LOGTAG, "skipping backup for " + backupFile.getHeader().getJid());
154                            } else {
155                                backupFiles.add(backupFile);
156                            }
157                        } catch (IOException | IllegalArgumentException e) {
158                            Log.d(Config.LOGTAG, "unable to read backup file ", e);
159                        }
160                    }
161                }
162            }
163            Collections.sort(backupFiles, (a, b) -> a.header.getJid().toString().compareTo(b.header.getJid().toString()));
164            onBackupFilesLoaded.onBackupFilesLoaded(backupFiles);
165        });
166    }
167
168    private void startForegroundService() {
169        startForeground(NOTIFICATION_ID, createImportBackupNotification(1, 0));
170    }
171
172    private void updateImportBackupNotification(final long total, final long current) {
173        final int max;
174        final int progress;
175        if (total == 0) {
176            max = 1;
177            progress = 0;
178        } else {
179            max = 100;
180            progress = (int) (current * 100 / total);
181        }
182        final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
183        try {
184            notificationManager.notify(NOTIFICATION_ID, createImportBackupNotification(max, progress));
185        } catch (final RuntimeException e) {
186            Log.d(Config.LOGTAG, "unable to make notification", e);
187        }
188    }
189
190    private Notification createImportBackupNotification(final int max, final int progress) {
191        NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
192        mBuilder.setContentTitle(getString(R.string.restoring_backup))
193                .setSmallIcon(R.drawable.ic_unarchive_white_24dp)
194                .setProgress(max, progress, max == 1 && progress == 0);
195        return mBuilder.build();
196    }
197
198    private boolean importBackup(final Uri uri, final String password) {
199        Log.d(Config.LOGTAG, "importing backup from " + uri);
200        final Stopwatch stopwatch = Stopwatch.createStarted();
201        try {
202            final SQLiteDatabase db = mDatabaseBackend.getWritableDatabase();
203            final InputStream inputStream;
204            final String path = uri.getPath();
205            final long fileSize;
206            if ("file".equals(uri.getScheme()) && path != null) {
207                final File file = new File(path);
208                inputStream = new FileInputStream(file);
209                fileSize = file.length();
210            } else {
211                final Cursor returnCursor = getContentResolver().query(uri, null, null, null, null);
212                if (returnCursor == null) {
213                    fileSize = 0;
214                } else {
215                    returnCursor.moveToFirst();
216                    fileSize = returnCursor.getLong(returnCursor.getColumnIndex(OpenableColumns.SIZE));
217                    returnCursor.close();
218                }
219                inputStream = getContentResolver().openInputStream(uri);
220            }
221            if (inputStream == null) {
222                synchronized (mOnBackupProcessedListeners) {
223                    for (final OnBackupProcessed l : mOnBackupProcessedListeners) {
224                        l.onBackupRestoreFailed();
225                    }
226                }
227                return false;
228            }
229            final CountingInputStream countingInputStream = new CountingInputStream(inputStream);
230            final DataInputStream dataInputStream = new DataInputStream(countingInputStream);
231            final BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
232            Log.d(Config.LOGTAG, backupFileHeader.toString());
233
234            if (mDatabaseBackend.getAccountJids(false).contains(backupFileHeader.getJid())) {
235                synchronized (mOnBackupProcessedListeners) {
236                    for (OnBackupProcessed l : mOnBackupProcessedListeners) {
237                        l.onAccountAlreadySetup();
238                    }
239                }
240                return false;
241            }
242
243            final byte[] key = ExportBackupWorker.getKey(password, backupFileHeader.getSalt());
244
245            final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
246            cipher.init(false, new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv()));
247            final CipherInputStream cipherInputStream = new CipherInputStream(countingInputStream, cipher);
248
249            final GZIPInputStream gzipInputStream = new GZIPInputStream(cipherInputStream);
250            final BufferedReader reader = new BufferedReader(new InputStreamReader(gzipInputStream, Charsets.UTF_8));
251            db.beginTransaction();
252            String line;
253            StringBuilder multiLineQuery = null;
254            while ((line = reader.readLine()) != null) {
255                int count = count(line, '\'');
256                if (multiLineQuery != null) {
257                    multiLineQuery.append('\n');
258                    multiLineQuery.append(line);
259                    if (count % 2 == 1) {
260                        db.execSQL(multiLineQuery.toString());
261                        multiLineQuery = null;
262                        updateImportBackupNotification(fileSize, countingInputStream.getCount());
263                    }
264                } else {
265                    if (count % 2 == 0) {
266                        if (line.startsWith("INSERT INTO cheogram.webxdc_updates(serial,")) {
267                            // re-number webxdc using autoincrement in the local database
268                            line = line.replace("webxdc_updates(serial,", "webxdc_updates(").replaceAll("\\([0-9]+,", "(");
269                        }
270                        db.execSQL(line);
271                        updateImportBackupNotification(fileSize, countingInputStream.getCount());
272                    } else {
273                        multiLineQuery = new StringBuilder(line);
274                    }
275                }
276            }
277            db.setTransactionSuccessful();
278            db.endTransaction();
279            final Jid jid = backupFileHeader.getJid();
280            final Cursor countCursor = db.rawQuery("select count(messages.uuid) from messages join conversations on conversations.uuid=messages.conversationUuid join accounts on conversations.accountUuid=accounts.uuid where accounts.username=? and accounts.server=?", new String[]{jid.getLocal(), jid.getDomain().toString()});
281            countCursor.moveToFirst();
282            final int count = countCursor.getInt(0);
283            Log.d(Config.LOGTAG, String.format("restored %d messages in %s", count, stopwatch.stop().toString()));
284            countCursor.close();
285            stopBackgroundService();
286            synchronized (mOnBackupProcessedListeners) {
287                for (OnBackupProcessed l : mOnBackupProcessedListeners) {
288                    l.onBackupRestored();
289                }
290            }
291            return true;
292        } catch (final Exception e) {
293            final Throwable throwable = e.getCause();
294            final boolean reasonWasCrypto = throwable instanceof BadPaddingException || e instanceof ZipException;
295            synchronized (mOnBackupProcessedListeners) {
296                for (OnBackupProcessed l : mOnBackupProcessedListeners) {
297                    if (reasonWasCrypto) {
298                        l.onBackupDecryptionFailed();
299                    } else {
300                        l.onBackupRestoreFailed();
301                    }
302                }
303            }
304            Log.d(Config.LOGTAG, "error restoring backup " + uri, e);
305            return false;
306        }
307    }
308
309    private void notifySuccess() {
310        NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
311        mBuilder.setContentTitle(getString(R.string.notification_restored_backup_title))
312                .setContentText(getString(R.string.notification_restored_backup_subtitle))
313                .setAutoCancel(true)
314                .setContentIntent(PendingIntent.getActivity(this, 145, new Intent(this, ManageAccountActivity.class), s()
315                        ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
316                        : PendingIntent.FLAG_UPDATE_CURRENT))
317                .setSmallIcon(R.drawable.ic_unarchive_white_24dp);
318        notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
319    }
320
321    private void stopBackgroundService() {
322        Intent intent = new Intent(this, XmppConnectionService.class);
323        stopService(intent);
324    }
325
326    public void removeOnBackupProcessedListener(OnBackupProcessed listener) {
327        synchronized (mOnBackupProcessedListeners) {
328            mOnBackupProcessedListeners.remove(listener);
329        }
330    }
331
332    public void addOnBackupProcessedListener(OnBackupProcessed listener) {
333        synchronized (mOnBackupProcessedListeners) {
334            mOnBackupProcessedListeners.add(listener);
335        }
336    }
337
338    @Override
339    public IBinder onBind(Intent intent) {
340        return this.binder;
341    }
342
343    public interface OnBackupFilesLoaded {
344        void onBackupFilesLoaded(List<BackupFile> files);
345    }
346
347    public interface OnBackupProcessed {
348        void onBackupRestored();
349
350        void onBackupDecryptionFailed();
351
352        void onBackupRestoreFailed();
353
354        void onAccountAlreadySetup();
355    }
356
357    public static class BackupFile {
358        private final Uri uri;
359        private final BackupFileHeader header;
360
361        private BackupFile(Uri uri, BackupFileHeader header) {
362            this.uri = uri;
363            this.header = header;
364        }
365
366        private static BackupFile read(File file) throws IOException {
367            final FileInputStream fileInputStream = new FileInputStream(file);
368            final DataInputStream dataInputStream = new DataInputStream(fileInputStream);
369            BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
370            fileInputStream.close();
371            return new BackupFile(Uri.fromFile(file), backupFileHeader);
372        }
373
374        public static BackupFile read(final Context context, final Uri uri) throws IOException {
375            final InputStream inputStream = context.getContentResolver().openInputStream(uri);
376            if (inputStream == null) {
377                throw new FileNotFoundException();
378            }
379            final DataInputStream dataInputStream = new DataInputStream(inputStream);
380            BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
381            inputStream.close();
382            return new BackupFile(uri, backupFileHeader);
383        }
384
385        public BackupFileHeader getHeader() {
386            return header;
387        }
388
389        public Uri getUri() {
390            return uri;
391        }
392    }
393
394    public class ImportBackupServiceBinder extends Binder {
395        public ImportBackupService getService() {
396            return ImportBackupService.this;
397        }
398    }
399}