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