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