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