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 db.execSQL(line);
266 updateImportBackupNotification(fileSize, countingInputStream.getCount());
267 } else {
268 multiLineQuery = new StringBuilder(line);
269 }
270 }
271 }
272 db.setTransactionSuccessful();
273 db.endTransaction();
274 final Jid jid = backupFileHeader.getJid();
275 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()});
276 countCursor.moveToFirst();
277 final int count = countCursor.getInt(0);
278 Log.d(Config.LOGTAG, String.format("restored %d messages in %s", count, stopwatch.stop().toString()));
279 countCursor.close();
280 stopBackgroundService();
281 synchronized (mOnBackupProcessedListeners) {
282 for (OnBackupProcessed l : mOnBackupProcessedListeners) {
283 l.onBackupRestored();
284 }
285 }
286 return true;
287 } catch (final Exception e) {
288 final Throwable throwable = e.getCause();
289 final boolean reasonWasCrypto = throwable instanceof BadPaddingException || e instanceof ZipException;
290 synchronized (mOnBackupProcessedListeners) {
291 for (OnBackupProcessed l : mOnBackupProcessedListeners) {
292 if (reasonWasCrypto) {
293 l.onBackupDecryptionFailed();
294 } else {
295 l.onBackupRestoreFailed();
296 }
297 }
298 }
299 Log.d(Config.LOGTAG, "error restoring backup " + uri, e);
300 return false;
301 }
302 }
303
304 private void notifySuccess() {
305 NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
306 mBuilder.setContentTitle(getString(R.string.notification_restored_backup_title))
307 .setContentText(getString(R.string.notification_restored_backup_subtitle))
308 .setAutoCancel(true)
309 .setContentIntent(PendingIntent.getActivity(this, 145, new Intent(this, ManageAccountActivity.class), s()
310 ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
311 : PendingIntent.FLAG_UPDATE_CURRENT))
312 .setSmallIcon(R.drawable.ic_unarchive_white_24dp);
313 notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
314 }
315
316 private void stopBackgroundService() {
317 Intent intent = new Intent(this, XmppConnectionService.class);
318 stopService(intent);
319 }
320
321 public void removeOnBackupProcessedListener(OnBackupProcessed listener) {
322 synchronized (mOnBackupProcessedListeners) {
323 mOnBackupProcessedListeners.remove(listener);
324 }
325 }
326
327 public void addOnBackupProcessedListener(OnBackupProcessed listener) {
328 synchronized (mOnBackupProcessedListeners) {
329 mOnBackupProcessedListeners.add(listener);
330 }
331 }
332
333 @Override
334 public IBinder onBind(Intent intent) {
335 return this.binder;
336 }
337
338 public interface OnBackupFilesLoaded {
339 void onBackupFilesLoaded(List<BackupFile> files);
340 }
341
342 public interface OnBackupProcessed {
343 void onBackupRestored();
344
345 void onBackupDecryptionFailed();
346
347 void onBackupRestoreFailed();
348
349 void onAccountAlreadySetup();
350 }
351
352 public static class BackupFile {
353 private final Uri uri;
354 private final BackupFileHeader header;
355
356 private BackupFile(Uri uri, BackupFileHeader header) {
357 this.uri = uri;
358 this.header = header;
359 }
360
361 private static BackupFile read(File file) throws IOException {
362 final FileInputStream fileInputStream = new FileInputStream(file);
363 final DataInputStream dataInputStream = new DataInputStream(fileInputStream);
364 BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
365 fileInputStream.close();
366 return new BackupFile(Uri.fromFile(file), backupFileHeader);
367 }
368
369 public static BackupFile read(final Context context, final Uri uri) throws IOException {
370 final InputStream inputStream = context.getContentResolver().openInputStream(uri);
371 if (inputStream == null) {
372 throw new FileNotFoundException();
373 }
374 final DataInputStream dataInputStream = new DataInputStream(inputStream);
375 BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
376 inputStream.close();
377 return new BackupFile(uri, backupFileHeader);
378 }
379
380 public BackupFileHeader getHeader() {
381 return header;
382 }
383
384 public Uri getUri() {
385 return uri;
386 }
387 }
388
389 public class ImportBackupServiceBinder extends Binder {
390 public ImportBackupService getService() {
391 return ImportBackupService.this;
392 }
393 }
394}