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