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