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