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