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