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}