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}