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