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}