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}