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