1package eu.siacs.conversations.worker;
2
3import static eu.siacs.conversations.utils.Compatibility.s;
4
5import android.app.Notification;
6import android.app.NotificationManager;
7import android.app.PendingIntent;
8import android.content.ContentValues;
9import android.content.Context;
10import android.content.Intent;
11import android.content.pm.ServiceInfo;
12import android.database.Cursor;
13import android.database.sqlite.SQLiteDatabase;
14import android.net.Uri;
15import android.provider.OpenableColumns;
16import android.util.Log;
17import androidx.annotation.NonNull;
18import androidx.core.app.NotificationCompat;
19import androidx.work.Data;
20import androidx.work.ForegroundInfo;
21import androidx.work.Worker;
22import androidx.work.WorkerParameters;
23import com.google.common.base.Stopwatch;
24import com.google.common.base.Strings;
25import com.google.common.collect.ImmutableList;
26import com.google.common.io.CountingInputStream;
27import com.google.gson.stream.JsonReader;
28import com.google.gson.stream.JsonToken;
29import eu.siacs.conversations.Config;
30import eu.siacs.conversations.R;
31import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
32import eu.siacs.conversations.entities.Account;
33import eu.siacs.conversations.entities.Conversation;
34import eu.siacs.conversations.entities.Message;
35import eu.siacs.conversations.persistance.DatabaseBackend;
36import eu.siacs.conversations.services.QuickConversationsService;
37import eu.siacs.conversations.services.XmppConnectionService;
38import eu.siacs.conversations.utils.AccountUtils;
39import eu.siacs.conversations.utils.BackupFileHeader;
40import eu.siacs.conversations.xmpp.Jid;
41import java.io.BufferedReader;
42import java.io.DataInputStream;
43import java.io.File;
44import java.io.FileInputStream;
45import java.io.FileNotFoundException;
46import java.io.IOException;
47import java.io.InputStream;
48import java.io.InputStreamReader;
49import java.nio.charset.StandardCharsets;
50import java.security.spec.InvalidKeySpecException;
51import java.util.Arrays;
52import java.util.Collection;
53import java.util.List;
54import java.util.regex.Pattern;
55import java.util.zip.GZIPInputStream;
56import java.util.zip.ZipException;
57import javax.crypto.BadPaddingException;
58import org.bouncycastle.crypto.engines.AESEngine;
59import org.bouncycastle.crypto.io.CipherInputStream;
60import org.bouncycastle.crypto.modes.AEADBlockCipher;
61import org.bouncycastle.crypto.modes.GCMBlockCipher;
62import org.bouncycastle.crypto.params.AEADParameters;
63import org.bouncycastle.crypto.params.KeyParameter;
64import org.json.JSONException;
65import org.json.JSONObject;
66
67public class ImportBackupWorker extends Worker {
68
69 public static final String TAG_IMPORT_BACKUP = "tag-import-backup";
70
71 private static final String DATA_KEY_PASSWORD = "password";
72 private static final String DATA_KEY_URI = "uri";
73 private static final String DATA_KEY_INCLUDE_OMEMO = "omemo";
74
75 private static final Collection<String> OMEMO_TABLE_LIST =
76 Arrays.asList(
77 SQLiteAxolotlStore.PREKEY_TABLENAME,
78 SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
79 SQLiteAxolotlStore.SESSION_TABLENAME,
80 SQLiteAxolotlStore.IDENTITIES_TABLENAME);
81
82 private static final List<String> TABLE_ALLOW_LIST =
83 new ImmutableList.Builder<String>()
84 .add(Account.TABLENAME, Conversation.TABLENAME, Message.TABLENAME)
85 .addAll(OMEMO_TABLE_LIST)
86 .build();
87
88 private static final Pattern COLUMN_PATTERN = Pattern.compile("^[a-zA-Z_]+$");
89
90 private static final int NOTIFICATION_ID = 21;
91
92 private final String password;
93 private final Uri uri;
94 private final boolean includeOmemo;
95
96 public ImportBackupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
97 super(context, workerParams);
98 final var inputData = workerParams.getInputData();
99 this.password = inputData.getString(DATA_KEY_PASSWORD);
100 this.uri = Uri.parse(inputData.getString(DATA_KEY_URI));
101 this.includeOmemo = inputData.getBoolean(DATA_KEY_INCLUDE_OMEMO, true);
102 }
103
104 @NonNull
105 @Override
106 public Result doWork() {
107 setForegroundAsync(getForegroundInfo());
108 final Result result;
109 try {
110 result = importBackup(this.uri, this.password);
111 } catch (final FileNotFoundException e) {
112 return failure(Reason.FILE_NOT_FOUND);
113 } catch (final Exception e) {
114 Log.d(Config.LOGTAG, "error restoring backup " + uri, e);
115 final Throwable throwable = e.getCause();
116 if (throwable instanceof BadPaddingException || e instanceof ZipException) {
117 return failure(Reason.DECRYPTION_FAILED);
118 } else {
119 return failure(Reason.GENERIC);
120 }
121 } finally {
122 getApplicationContext()
123 .getSystemService(NotificationManager.class)
124 .cancel(NOTIFICATION_ID);
125 }
126
127 return result;
128 }
129
130 @NonNull
131 @Override
132 public ForegroundInfo getForegroundInfo() {
133 final var notification = createImportBackupNotification(1, 0);
134 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
135 return new ForegroundInfo(
136 NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
137 } else {
138 return new ForegroundInfo(NOTIFICATION_ID, notification);
139 }
140 }
141
142 private Result importBackup(final Uri uri, final String password)
143 throws IOException, InvalidKeySpecException {
144 final var context = getApplicationContext();
145 final var database = DatabaseBackend.getInstance(context);
146 Log.d(Config.LOGTAG, "importing backup from " + uri);
147 final Stopwatch stopwatch = Stopwatch.createStarted();
148 final SQLiteDatabase db = database.getWritableDatabase();
149 final InputStream inputStream;
150 final String path = uri.getPath();
151 final long fileSize;
152 if ("file".equals(uri.getScheme()) && path != null) {
153 final File file = new File(path);
154 inputStream = new FileInputStream(file);
155 fileSize = file.length();
156 } else {
157 final Cursor returnCursor =
158 context.getContentResolver().query(uri, null, null, null, null);
159 if (returnCursor == null) {
160 fileSize = 0;
161 } else {
162 returnCursor.moveToFirst();
163 fileSize =
164 returnCursor.getLong(
165 returnCursor.getColumnIndexOrThrow(OpenableColumns.SIZE));
166 returnCursor.close();
167 }
168 inputStream = context.getContentResolver().openInputStream(uri);
169 }
170 if (inputStream == null) {
171 return failure(Reason.FILE_NOT_FOUND);
172 }
173 final CountingInputStream countingInputStream = new CountingInputStream(inputStream);
174 final DataInputStream dataInputStream = new DataInputStream(countingInputStream);
175 final BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
176 Log.d(Config.LOGTAG, backupFileHeader.toString());
177
178 final var accounts = database.getAccountJids(false);
179
180 if (QuickConversationsService.isQuicksy() && !accounts.isEmpty()) {
181 return failure(Reason.ACCOUNT_ALREADY_EXISTS);
182 }
183
184 if (accounts.contains(backupFileHeader.getJid())) {
185 return failure(Reason.ACCOUNT_ALREADY_EXISTS);
186 }
187
188 final byte[] key = ExportBackupWorker.getKey(password, backupFileHeader.getSalt());
189
190 final AEADBlockCipher cipher = GCMBlockCipher.newInstance(AESEngine.newInstance());
191 cipher.init(
192 false, new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv()));
193 final CipherInputStream cipherInputStream =
194 new CipherInputStream(countingInputStream, cipher);
195
196 final GZIPInputStream gzipInputStream = new GZIPInputStream(cipherInputStream);
197 final BufferedReader reader =
198 new BufferedReader(new InputStreamReader(gzipInputStream, StandardCharsets.UTF_8));
199 final JsonReader jsonReader = new JsonReader(reader);
200 if (jsonReader.peek() == JsonToken.BEGIN_ARRAY) {
201 jsonReader.beginArray();
202 } else {
203 throw new IllegalStateException("Backup file did not begin with array");
204 }
205 db.beginTransaction();
206 while (jsonReader.hasNext()) {
207 if (jsonReader.peek() == JsonToken.BEGIN_OBJECT) {
208 importRow(db, jsonReader, backupFileHeader.getJid(), password);
209 } else if (jsonReader.peek() == JsonToken.END_ARRAY) {
210 jsonReader.endArray();
211 continue;
212 }
213 updateImportBackupNotification(fileSize, countingInputStream.getCount());
214 }
215 db.setTransactionSuccessful();
216 db.endTransaction();
217 final Jid jid = backupFileHeader.getJid();
218 final Cursor countCursor =
219 db.rawQuery(
220 "select count(messages.uuid) from messages join conversations on"
221 + " conversations.uuid=messages.conversationUuid join accounts on"
222 + " conversations.accountUuid=accounts.uuid where"
223 + " accounts.username=? and accounts.server=?",
224 new String[] {jid.getLocal(), jid.getDomain().toString()});
225 countCursor.moveToFirst();
226 final int count = countCursor.getInt(0);
227 Log.d(Config.LOGTAG, String.format("restored %d messages in %s", count, stopwatch.stop()));
228 countCursor.close();
229 stopBackgroundService();
230 notifySuccess();
231 return Result.success();
232 }
233
234 private void importRow(
235 final SQLiteDatabase db,
236 final JsonReader jsonReader,
237 final Jid account,
238 final String passphrase)
239 throws IOException {
240 jsonReader.beginObject();
241 final String firstParameter = jsonReader.nextName();
242 if (!firstParameter.equals("table")) {
243 throw new IllegalStateException("Expected key 'table'");
244 }
245 final String table = jsonReader.nextString();
246 if (!TABLE_ALLOW_LIST.contains(table)) {
247 throw new IOException(String.format("%s is not recognized for import", table));
248 }
249 final ContentValues contentValues = new ContentValues();
250 final String secondParameter = jsonReader.nextName();
251 if (!secondParameter.equals("values")) {
252 throw new IllegalStateException("Expected key 'values'");
253 }
254 jsonReader.beginObject();
255 while (jsonReader.peek() != JsonToken.END_OBJECT) {
256 final String name = jsonReader.nextName();
257 if (COLUMN_PATTERN.matcher(name).matches()) {
258 if (jsonReader.peek() == JsonToken.NULL) {
259 jsonReader.nextNull();
260 contentValues.putNull(name);
261 } else if (jsonReader.peek() == JsonToken.NUMBER) {
262 contentValues.put(name, jsonReader.nextLong());
263 } else {
264 contentValues.put(name, jsonReader.nextString());
265 }
266 } else {
267 throw new IOException(String.format("Unexpected column name %s", name));
268 }
269 }
270 jsonReader.endObject();
271 jsonReader.endObject();
272 if (Account.TABLENAME.equals(table)) {
273 final Jid jid =
274 Jid.of(
275 contentValues.getAsString(Account.USERNAME),
276 contentValues.getAsString(Account.SERVER),
277 null);
278 final String password = contentValues.getAsString(Account.PASSWORD);
279 if (QuickConversationsService.isQuicksy()) {
280 if (!jid.getDomain().equals(Config.QUICKSY_DOMAIN)) {
281 throw new IOException("Trying to restore non Quicksy account on Quicksy");
282 }
283 }
284 if (jid.equals(account) && passphrase.equals(password)) {
285 Log.d(Config.LOGTAG, "jid and password from backup header had matching row");
286 } else {
287 throw new IOException("jid or password in table did not match backup");
288 }
289 final var keys = Account.parseKeys(contentValues.getAsString(Account.KEYS));
290 final var deviceId = keys.optString(SQLiteAxolotlStore.JSONKEY_REGISTRATION_ID);
291 final var importReadyKeys = new JSONObject();
292 if (!Strings.isNullOrEmpty(deviceId) && this.includeOmemo) {
293 try {
294 importReadyKeys.put(SQLiteAxolotlStore.JSONKEY_REGISTRATION_ID, deviceId);
295 } catch (final JSONException e) {
296 Log.e(Config.LOGTAG, "error writing omemo registration id", e);
297 }
298 }
299 contentValues.put(Account.KEYS, importReadyKeys.toString());
300 }
301 if (this.includeOmemo) {
302 db.insert(table, null, contentValues);
303 } else {
304 if (OMEMO_TABLE_LIST.contains(table)) {
305 if (SQLiteAxolotlStore.IDENTITIES_TABLENAME.equals(table)
306 && contentValues.getAsInteger(SQLiteAxolotlStore.OWN) == 0) {
307 db.insert(table, null, contentValues);
308 } else {
309 Log.d(Config.LOGTAG, "skipping over omemo key material in table " + table);
310 }
311 } else {
312 db.insert(table, null, contentValues);
313 }
314 }
315 }
316
317 private void stopBackgroundService() {
318 final var intent = new Intent(getApplicationContext(), XmppConnectionService.class);
319 getApplicationContext().stopService(intent);
320 }
321
322 private void updateImportBackupNotification(final long total, final long current) {
323 final int max;
324 final int progress;
325 if (total == 0) {
326 max = 1;
327 progress = 0;
328 } else {
329 max = 100;
330 progress = (int) (current * 100 / total);
331 }
332 getApplicationContext()
333 .getSystemService(NotificationManager.class)
334 .notify(NOTIFICATION_ID, createImportBackupNotification(max, progress));
335 }
336
337 private Notification createImportBackupNotification(final int max, final int progress) {
338 final var context = getApplicationContext();
339 final var builder = new NotificationCompat.Builder(getApplicationContext(), "backup");
340 builder.setContentTitle(context.getString(R.string.restoring_backup))
341 .setSmallIcon(R.drawable.ic_unarchive_24dp)
342 .setProgress(max, progress, max == 1 && progress == 0);
343 return builder.build();
344 }
345
346 private void notifySuccess() {
347 final var context = getApplicationContext();
348 final var builder = new NotificationCompat.Builder(context, "backup");
349 builder.setContentTitle(context.getString(R.string.notification_restored_backup_title))
350 .setContentText(context.getString(R.string.notification_restored_backup_subtitle))
351 .setAutoCancel(true)
352 .setSmallIcon(R.drawable.ic_unarchive_24dp);
353 if (QuickConversationsService.isConversations()
354 && AccountUtils.MANAGE_ACCOUNT_ACTIVITY != null) {
355 builder.setContentText(
356 context.getString(R.string.notification_restored_backup_subtitle));
357 builder.setContentIntent(
358 PendingIntent.getActivity(
359 context,
360 145,
361 new Intent(context, AccountUtils.MANAGE_ACCOUNT_ACTIVITY),
362 s()
363 ? PendingIntent.FLAG_IMMUTABLE
364 | PendingIntent.FLAG_UPDATE_CURRENT
365 : PendingIntent.FLAG_UPDATE_CURRENT));
366 }
367 getApplicationContext()
368 .getSystemService(NotificationManager.class)
369 .notify(NOTIFICATION_ID + 2, builder.build());
370 }
371
372 public static Data data(final String password, final Uri uri, final boolean includeOmemo) {
373 return new Data.Builder()
374 .putString(DATA_KEY_PASSWORD, password)
375 .putString(DATA_KEY_URI, uri.toString())
376 .putBoolean(DATA_KEY_INCLUDE_OMEMO, includeOmemo)
377 .build();
378 }
379
380 private static Result failure(final Reason reason) {
381 return Result.failure(new Data.Builder().putString("reason", reason.toString()).build());
382 }
383
384 public enum Reason {
385 ACCOUNT_ALREADY_EXISTS,
386 DECRYPTION_FAILED,
387 FILE_NOT_FOUND,
388 GENERIC;
389
390 public static Reason valueOfOrGeneric(final String value) {
391 if (Strings.isNullOrEmpty(value)) {
392 return GENERIC;
393 }
394 try {
395 return valueOf(value);
396 } catch (final IllegalArgumentException e) {
397 return GENERIC;
398 }
399 }
400 }
401}