ImportBackupActivity.java

  1package eu.siacs.conversations.ui;
  2
  3import android.Manifest;
  4import android.content.DialogInterface;
  5import android.content.Intent;
  6import android.content.pm.PackageManager;
  7import android.net.Uri;
  8import android.os.Build;
  9import android.os.Bundle;
 10import android.util.Log;
 11import android.view.LayoutInflater;
 12import android.view.Menu;
 13import android.view.MenuItem;
 14import android.view.View;
 15import androidx.activity.result.ActivityResultLauncher;
 16import androidx.activity.result.contract.ActivityResultContracts;
 17import androidx.annotation.NonNull;
 18import androidx.appcompat.app.AlertDialog;
 19import androidx.core.content.ContextCompat;
 20import androidx.databinding.DataBindingUtil;
 21import androidx.lifecycle.LiveData;
 22import androidx.lifecycle.Transformations;
 23import androidx.work.OneTimeWorkRequest;
 24import androidx.work.OutOfQuotaPolicy;
 25import androidx.work.WorkInfo;
 26import androidx.work.WorkManager;
 27import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 28import com.google.android.material.snackbar.Snackbar;
 29import com.google.common.collect.ImmutableList;
 30import com.google.common.collect.ImmutableSet;
 31import com.google.common.collect.Iterables;
 32import com.google.common.util.concurrent.FutureCallback;
 33import com.google.common.util.concurrent.Futures;
 34import eu.siacs.conversations.Config;
 35import eu.siacs.conversations.R;
 36import eu.siacs.conversations.databinding.ActivityImportBackupBinding;
 37import eu.siacs.conversations.databinding.DialogEnterPasswordBinding;
 38import eu.siacs.conversations.services.QuickConversationsService;
 39import eu.siacs.conversations.ui.adapter.BackupFileAdapter;
 40import eu.siacs.conversations.utils.BackupFile;
 41import eu.siacs.conversations.utils.BackupFileHeader;
 42import eu.siacs.conversations.worker.ImportBackupWorker;
 43import java.io.IOException;
 44import java.util.Collections;
 45import java.util.List;
 46import java.util.Set;
 47import java.util.UUID;
 48
 49public class ImportBackupActivity extends ActionBarActivity
 50        implements BackupFileAdapter.OnItemClickedListener {
 51
 52    private ActivityImportBackupBinding binding;
 53
 54    private BackupFileAdapter backupFileAdapter;
 55
 56    private LiveData<Boolean> inProgressImport;
 57    private Uri currentRestoreDialog;
 58    private UUID currentWorkRequest;
 59
 60    private final ActivityResultLauncher<String[]> requestPermissions =
 61            registerForActivityResult(
 62                    new ActivityResultContracts.RequestMultiplePermissions(),
 63                    results -> {
 64                        if (results.containsValue(Boolean.TRUE)) {
 65                            loadBackupFiles();
 66                        }
 67                    });
 68
 69    private final ActivityResultLauncher<String> openBackup =
 70            registerForActivityResult(
 71                    new ActivityResultContracts.GetContent(),
 72                    uri -> openBackupFileFromUri(uri, false));
 73
 74    @Override
 75    protected void onCreate(final Bundle savedInstanceState) {
 76        super.onCreate(savedInstanceState);
 77        binding = DataBindingUtil.setContentView(this, R.layout.activity_import_backup);
 78        Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
 79        setSupportActionBar(binding.toolbar);
 80
 81        final var workManager = WorkManager.getInstance(this);
 82        final var imports =
 83                workManager.getWorkInfosByTagLiveData(ImportBackupWorker.TAG_IMPORT_BACKUP);
 84        this.inProgressImport =
 85                Transformations.map(
 86                        imports, infos -> Iterables.any(infos, i -> !i.getState().isFinished()));
 87
 88        this.inProgressImport.observe(
 89                this, inProgress -> setLoadingState(Boolean.TRUE.equals(inProgress)));
 90
 91        if (savedInstanceState != null) {
 92            final var currentWorkRequest = savedInstanceState.getString("current-work-request");
 93            if (currentWorkRequest != null) {
 94                this.currentWorkRequest = UUID.fromString(currentWorkRequest);
 95            }
 96            final var currentRestoreDialog = savedInstanceState.getString("current-restore-dialog");
 97            if (currentRestoreDialog != null) {
 98                this.currentRestoreDialog = Uri.parse(currentRestoreDialog);
 99            }
100        }
101        monitorWorkRequest(this.currentWorkRequest);
102
103        this.backupFileAdapter = new BackupFileAdapter();
104        this.binding.list.setAdapter(this.backupFileAdapter);
105        this.backupFileAdapter.setOnItemClickedListener(this);
106        if (this.currentRestoreDialog != null) {
107            openBackupFileFromUri(this.currentRestoreDialog, false);
108        }
109    }
110
111    @Override
112    public boolean onCreateOptionsMenu(final Menu menu) {
113        getMenuInflater().inflate(R.menu.import_backup, menu);
114        final MenuItem openBackup = menu.findItem(R.id.action_open_backup_file);
115        final var inProgress =
116                this.inProgressImport == null ? null : this.inProgressImport.getValue();
117        openBackup.setVisible(!Boolean.TRUE.equals(inProgress));
118        return true;
119    }
120
121    @Override
122    public void onSaveInstanceState(@NonNull final Bundle bundle) {
123        if (this.currentWorkRequest != null) {
124            bundle.putString("current-work-request", this.currentWorkRequest.toString());
125        }
126        if (this.currentRestoreDialog != null) {
127            bundle.putString("current-restore-dialog", this.currentRestoreDialog.toString());
128        }
129        super.onSaveInstanceState(bundle);
130    }
131
132    @Override
133    public void onStart() {
134        super.onStart();
135
136        final var intent = getIntent();
137        final var action = intent == null ? null : intent.getAction();
138        final var data = intent == null ? null : intent.getData();
139        if (Intent.ACTION_VIEW.equals(action) && data != null) {
140            openBackupFileFromUri(data, true);
141            setIntent(new Intent(Intent.ACTION_MAIN));
142            return;
143        }
144
145        final List<String> desiredPermission;
146        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
147            desiredPermission =
148                    ImmutableList.of(
149                            Manifest.permission.READ_MEDIA_IMAGES,
150                            Manifest.permission.READ_MEDIA_VIDEO,
151                            Manifest.permission.READ_MEDIA_AUDIO,
152                            Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED);
153        } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.TIRAMISU) {
154            desiredPermission =
155                    ImmutableList.of(
156                            Manifest.permission.READ_MEDIA_IMAGES,
157                            Manifest.permission.READ_MEDIA_VIDEO,
158                            Manifest.permission.READ_MEDIA_AUDIO);
159        } else {
160            desiredPermission = ImmutableList.of(Manifest.permission.READ_EXTERNAL_STORAGE);
161        }
162        final Set<String> declaredPermission = getDeclaredPermission();
163        if (declaredPermission.containsAll(desiredPermission)) {
164            requestPermissions.launch(desiredPermission.toArray(new String[0]));
165        } else {
166            Log.d(Config.LOGTAG, "Manifest is lacking some desired permission. not requesting");
167        }
168    }
169
170    private Set<String> getDeclaredPermission() {
171        final String[] permissions;
172        try {
173            permissions =
174                    getPackageManager()
175                            .getPackageInfo(getPackageName(), PackageManager.GET_PERMISSIONS)
176                            .requestedPermissions;
177        } catch (final PackageManager.NameNotFoundException e) {
178            return Collections.emptySet();
179        }
180        return ImmutableSet.copyOf(permissions);
181    }
182
183    @Override
184    public void onStop() {
185        super.onStop();
186    }
187
188    private void loadBackupFiles() {
189        final var future = BackupFile.listAsync(getApplicationContext());
190        Futures.addCallback(
191                future,
192                new FutureCallback<>() {
193                    @Override
194                    public void onSuccess(List<BackupFile> files) {
195                        runOnUiThread(() -> backupFileAdapter.setFiles(files));
196                    }
197
198                    @Override
199                    public void onFailure(@NonNull Throwable t) {}
200                },
201                ContextCompat.getMainExecutor(getApplication()));
202    }
203
204    @Override
205    public void onClick(final BackupFile backupFile) {
206        showEnterPasswordDialog(backupFile, false);
207    }
208
209    private void openBackupFileFromUri(final Uri uri, final boolean finishOnCancel) {
210        final var backupFileFuture = BackupFile.readAsync(this, uri);
211        Futures.addCallback(
212                backupFileFuture,
213                new FutureCallback<>() {
214                    @Override
215                    public void onSuccess(final BackupFile backupFile) {
216                        if (QuickConversationsService.isQuicksy()) {
217                            if (!backupFile
218                                    .getHeader()
219                                    .getJid()
220                                    .getDomain()
221                                    .equals(Config.QUICKSY_DOMAIN)) {
222                                Snackbar.make(
223                                                binding.coordinator,
224                                                R.string.non_quicksy_backup,
225                                                Snackbar.LENGTH_LONG)
226                                        .show();
227                                return;
228                            }
229                        }
230                        showEnterPasswordDialog(backupFile, finishOnCancel);
231                    }
232
233                    @Override
234                    public void onFailure(@NonNull final Throwable throwable) {
235                        Log.d(Config.LOGTAG, "could not open backup file " + uri, throwable);
236                        showBackupThrowable(throwable);
237                    }
238                },
239                ContextCompat.getMainExecutor(getApplication()));
240    }
241
242    private void showBackupThrowable(final Throwable throwable) {
243        if (throwable instanceof BackupFileHeader.OutdatedBackupFileVersion) {
244            Snackbar.make(
245                            binding.coordinator,
246                            R.string.outdated_backup_file_format,
247                            Snackbar.LENGTH_LONG)
248                    .show();
249        } else if (throwable instanceof IOException
250                || throwable instanceof IllegalArgumentException) {
251            Snackbar.make(binding.coordinator, R.string.not_a_backup_file, Snackbar.LENGTH_LONG)
252                    .show();
253        } else if (throwable instanceof SecurityException e) {
254            Log.d(Config.LOGTAG, "not able to parse backup file", e);
255            Snackbar.make(
256                            binding.coordinator,
257                            R.string.sharing_application_not_grant_permission,
258                            Snackbar.LENGTH_LONG)
259                    .show();
260        }
261    }
262
263    private void showEnterPasswordDialog(
264            final BackupFile backupFile, final boolean finishOnCancel) {
265        this.currentRestoreDialog = backupFile.getUri();
266        final DialogEnterPasswordBinding enterPasswordBinding =
267                DataBindingUtil.inflate(
268                        LayoutInflater.from(this), R.layout.dialog_enter_password, null, false);
269        Log.d(Config.LOGTAG, "attempting to import " + backupFile.getUri());
270        enterPasswordBinding.explain.setText(
271                getString(
272                        R.string.enter_password_to_restore,
273                        backupFile.getHeader().getJid().toString()));
274        final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
275        builder.setView(enterPasswordBinding.getRoot());
276        builder.setTitle(R.string.enter_password);
277        builder.setNegativeButton(
278                R.string.cancel,
279                (dialog, which) -> {
280                    this.currentRestoreDialog = null;
281                    if (finishOnCancel) {
282                        finish();
283                    }
284                });
285        builder.setPositiveButton(R.string.restore, null);
286        builder.setCancelable(false);
287        final AlertDialog dialog = builder.create();
288        dialog.setOnShowListener((d) -> onDialogShow(backupFile, d, enterPasswordBinding));
289        dialog.show();
290    }
291
292    private void onDialogShow(
293            final BackupFile backupFile,
294            final DialogInterface d,
295            final DialogEnterPasswordBinding enterPasswordBinding) {
296        if (d instanceof AlertDialog alertDialog) {
297            alertDialog
298                    .getButton(DialogInterface.BUTTON_POSITIVE)
299                    .setOnClickListener(v -> onRestoreClick(backupFile, d, enterPasswordBinding));
300        }
301    }
302
303    private void onRestoreClick(
304            final BackupFile backupFile,
305            final DialogInterface d,
306            final DialogEnterPasswordBinding enterPasswordBinding) {
307        final String password = enterPasswordBinding.accountPassword.getEditableText().toString();
308        if (password.isEmpty()) {
309            enterPasswordBinding.accountPasswordLayout.setError(
310                    getString(R.string.please_enter_password));
311            return;
312        }
313
314        importBackup(backupFile, password, enterPasswordBinding.includeKeys.isChecked());
315        d.dismiss();
316    }
317
318    private void importBackup(
319            final BackupFile backupFile, final String password, final boolean includeOmemo) {
320        final OneTimeWorkRequest importBackupWorkRequest =
321                new OneTimeWorkRequest.Builder(ImportBackupWorker.class)
322                        .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
323                        .setInputData(
324                                ImportBackupWorker.data(
325                                        password, backupFile.getUri(), includeOmemo))
326                        .addTag(ImportBackupWorker.TAG_IMPORT_BACKUP)
327                        .build();
328
329        final var id = importBackupWorkRequest.getId();
330        this.currentWorkRequest = id;
331        monitorWorkRequest(id);
332
333        final var workManager = WorkManager.getInstance(this);
334        workManager.enqueue(importBackupWorkRequest);
335    }
336
337    private void monitorWorkRequest(final UUID uuid) {
338        if (uuid == null) {
339            return;
340        }
341        Log.d(Config.LOGTAG, "monitorWorkRequest(" + uuid + ")");
342        final var workInfoLiveData = WorkManager.getInstance(this).getWorkInfoByIdLiveData(uuid);
343        workInfoLiveData.observe(
344                this,
345                workInfo -> {
346                    final var state = workInfo.getState();
347                    if (state.isFinished()) {
348                        this.currentWorkRequest = null;
349                    }
350                    if (state == WorkInfo.State.FAILED) {
351                        final var data = workInfo.getOutputData();
352                        final var reason =
353                                ImportBackupWorker.Reason.valueOfOrGeneric(
354                                        data.getString("reason"));
355                        switch (reason) {
356                            case DECRYPTION_FAILED -> onBackupDecryptionFailed();
357                            case ACCOUNT_ALREADY_EXISTS -> onAccountAlreadySetup();
358                            default -> onBackupRestoreFailed();
359                        }
360                    } else if (state == WorkInfo.State.SUCCEEDED) {
361                        onBackupRestored();
362                    }
363                });
364    }
365
366    private void setLoadingState(final boolean loadingState) {
367        Log.d(Config.LOGTAG, "setLoadingState(" + loadingState + ")");
368        binding.coordinator.setVisibility(loadingState ? View.GONE : View.VISIBLE);
369        binding.inProgress.setVisibility(loadingState ? View.VISIBLE : View.GONE);
370        setTitle(loadingState ? R.string.restoring_backup : R.string.restore_backup);
371        Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
372        configureActionBar(getSupportActionBar(), !loadingState);
373        invalidateOptionsMenu();
374    }
375
376    private void onAccountAlreadySetup() {
377        Snackbar.make(binding.coordinator, R.string.account_already_setup, Snackbar.LENGTH_LONG)
378                .show();
379    }
380
381    private void onBackupRestored() {
382        final Intent intent = new Intent(this, ConversationActivity.class);
383        intent.addFlags(
384                Intent.FLAG_ACTIVITY_CLEAR_TOP
385                        | Intent.FLAG_ACTIVITY_NEW_TASK
386                        | Intent.FLAG_ACTIVITY_CLEAR_TASK);
387        startActivity(intent);
388        finish();
389    }
390
391    private void onBackupDecryptionFailed() {
392        Snackbar.make(binding.coordinator, R.string.unable_to_decrypt_backup, Snackbar.LENGTH_LONG)
393                .show();
394    }
395
396    private void onBackupRestoreFailed() {
397        Snackbar.make(binding.coordinator, R.string.unable_to_restore_backup, Snackbar.LENGTH_LONG)
398                .show();
399    }
400
401    @Override
402    public boolean onOptionsItemSelected(MenuItem item) {
403        if (item.getItemId() == R.id.action_open_backup_file) {
404            this.openBackup.launch("*/*");
405            return true;
406        }
407        return super.onOptionsItemSelected(item);
408    }
409}