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