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}