diff --git a/src/conversations/AndroidManifest.xml b/src/conversations/AndroidManifest.xml index d987d9062..2c01f6d6c 100644 --- a/src/conversations/AndroidManifest.xml +++ b/src/conversations/AndroidManifest.xml @@ -16,6 +16,10 @@ android:name=".ui.MagicCreateActivity" android:label="@string/create_account" android:launchMode="singleTask"/> + diff --git a/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java b/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java new file mode 100644 index 000000000..d7deb599c --- /dev/null +++ b/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java @@ -0,0 +1,276 @@ +package eu.siacs.conversations.services; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.database.sqlite.SQLiteDatabase; +import android.os.Binder; +import android.os.IBinder; +import android.support.v4.app.NotificationCompat; +import android.util.Log; + +import java.io.BufferedReader; +import java.io.DataInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.zip.GZIPInputStream; + +import javax.crypto.AEADBadTagException; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.persistance.DatabaseBackend; +import eu.siacs.conversations.persistance.FileBackend; +import eu.siacs.conversations.ui.ManageAccountActivity; +import eu.siacs.conversations.utils.BackupFileHeader; +import eu.siacs.conversations.utils.Compatibility; +import eu.siacs.conversations.utils.SerialSingleThreadExecutor; + +import static eu.siacs.conversations.services.ExportBackupService.CIPHERMODE; +import static eu.siacs.conversations.services.ExportBackupService.KEYTYPE; +import static eu.siacs.conversations.services.ExportBackupService.PROVIDER; + +public class ImportBackupService extends Service { + + private static final int NOTIFICATION_ID = 21; + + private final ImportBackupServiceBinder binder = new ImportBackupServiceBinder(); + private final SerialSingleThreadExecutor executor = new SerialSingleThreadExecutor(getClass().getSimpleName()); + + private final Set mOnBackupProcessedListeners = Collections.newSetFromMap(new WeakHashMap<>()); + + private static AtomicBoolean running = new AtomicBoolean(false); + private DatabaseBackend mDatabaseBackend; + private NotificationManager notificationManager; + + private static int count(String input, char c) { + int count = 0; + for (char aChar : input.toCharArray()) { + if (aChar == c) { + ++count; + } + } + return count; + } + + @Override + public void onCreate() { + mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext()); + notificationManager = (android.app.NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + return START_NOT_STICKY; + } + final String password = intent.getStringExtra("password"); + final String file = intent.getStringExtra("file"); + if (password == null || file == null) { + return START_NOT_STICKY; + } + Log.d(Config.LOGTAG, "on start command"); + if (running.compareAndSet(false, true)) { + executor.execute(() -> { + startForegroundService(); + final boolean success = importBackup(new File(file), password); + stopForeground(true); + running.set(false); + if (success) { + notifySuccess(); + } + stopSelf(); + }); + } else { + Log.d(Config.LOGTAG, "backup already running"); + } + return START_NOT_STICKY; + } + + public void loadBackupFiles(OnBackupFilesLoaded onBackupFilesLoaded) { + executor.execute(() -> { + final ArrayList backupFiles = new ArrayList<>(); + for (String app : Arrays.asList("Conversations", "Quicksy")) { + final File directory = new File(FileBackend.getBackupDirectory(app)); + if (!directory.exists() || !directory.isDirectory()) { + Log.d(Config.LOGTAG, "directory not found: " + directory.getAbsolutePath()); + continue; + } + for (File file : directory.listFiles()) { + if (file.isFile() && file.getName().endsWith(".ceb")) { + try { + backupFiles.add(BackupFile.read(file)); + } catch (IOException e) { + Log.d(Config.LOGTAG, "unable to read backup file ", e); + } + } + } + } + onBackupFilesLoaded.onBackupFilesLoaded(backupFiles); + }); + } + + private void startForegroundService() { + NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup"); + mBuilder.setContentTitle(getString(R.string.notification_restore_backup_title)) + .setSmallIcon(R.drawable.ic_unarchive_white_24dp) + .setProgress(1, 0, true); + startForeground(NOTIFICATION_ID, mBuilder.build()); + } + + private boolean importBackup(File file, String password) { + Log.d(Config.LOGTAG, "importing backup from file " + file.getAbsolutePath()); + try { + SQLiteDatabase db = mDatabaseBackend.getWritableDatabase(); + final FileInputStream fileInputStream = new FileInputStream(file); + final DataInputStream dataInputStream = new DataInputStream(fileInputStream); + BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream); + Log.d(Config.LOGTAG, backupFileHeader.toString()); + + final Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER); + byte[] key = ExportBackupService.getKey(password, backupFileHeader.getSalt()); + SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE); + IvParameterSpec ivSpec = new IvParameterSpec(backupFileHeader.getIv()); + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); + CipherInputStream cipherInputStream = new CipherInputStream(fileInputStream, cipher); + + GZIPInputStream gzipInputStream = new GZIPInputStream(cipherInputStream); + BufferedReader reader = new BufferedReader(new InputStreamReader(gzipInputStream, "UTF-8")); + String line; + StringBuilder multiLineQuery = null; + while ((line = reader.readLine()) != null) { + int count = count(line, '\''); + if (multiLineQuery != null) { + multiLineQuery.append(line); + if (count % 2 == 1) { + db.execSQL(multiLineQuery.toString()); + multiLineQuery = null; + } + } else { + if (count % 2 == 0) { + db.execSQL(line); + } else { + multiLineQuery = new StringBuilder(line); + } + } + } + Log.d(Config.LOGTAG, "done reading file"); + stopBackgroundService(); + synchronized (mOnBackupProcessedListeners) { + for (OnBackupProcessed l : mOnBackupProcessedListeners) { + l.onBackupRestored(); + } + } + return true; + } catch (Exception e) { + Throwable throwable = e.getCause(); + final boolean reasonWasCrypto; + if (throwable instanceof BadPaddingException) { + reasonWasCrypto = true; + } else { + reasonWasCrypto = false; + } + synchronized (mOnBackupProcessedListeners) { + for (OnBackupProcessed l : mOnBackupProcessedListeners) { + if (reasonWasCrypto) { + l.onBackupDecryptionFailed(); + } else { + l.onBackupRestoreFailed(); + } + } + } + Log.d(Config.LOGTAG, "error restoring backup " + file.getAbsolutePath(), e); + return false; + } + } + + private void notifySuccess() { + NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup"); + mBuilder.setContentTitle(getString(R.string.notification_restored_backup_title)) + .setContentText(getString(R.string.notification_restored_backup_subtitle)) + .setAutoCancel(true) + .setContentIntent(PendingIntent.getActivity(this, 145, new Intent(this, ManageAccountActivity.class), PendingIntent.FLAG_UPDATE_CURRENT)) + .setSmallIcon(R.drawable.ic_unarchive_white_24dp); + notificationManager.notify(NOTIFICATION_ID,mBuilder.build()); + } + + private void stopBackgroundService() { + Intent intent = new Intent(this, XmppConnectionService.class); + stopService(intent); + } + + public void removeOnBackupProcessedListener(OnBackupProcessed listener) { + synchronized (mOnBackupProcessedListeners) { + mOnBackupProcessedListeners.remove(listener); + } + } + + public void addOnBackupProcessedListener(OnBackupProcessed listener) { + synchronized (mOnBackupProcessedListeners) { + mOnBackupProcessedListeners.add(listener); + } + } + + @Override + public IBinder onBind(Intent intent) { + return this.binder; + } + + public static class BackupFile { + private final File file; + private final BackupFileHeader header; + + private BackupFile(File file, BackupFileHeader header) { + this.file = file; + this.header = header; + } + + private static BackupFile read(File file) throws IOException { + final FileInputStream fileInputStream = new FileInputStream(file); + final DataInputStream dataInputStream = new DataInputStream(fileInputStream); + BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream); + fileInputStream.close(); + return new BackupFile(file, backupFileHeader); + } + + public BackupFileHeader getHeader() { + return header; + } + + public File getFile() { + return file; + } + } + + public class ImportBackupServiceBinder extends Binder { + public ImportBackupService getService() { + return ImportBackupService.this; + } + } + + public interface OnBackupFilesLoaded { + void onBackupFilesLoaded(List files); + } + + public interface OnBackupProcessed { + void onBackupRestored(); + void onBackupDecryptionFailed(); + void onBackupRestoreFailed(); + } +} \ No newline at end of file diff --git a/src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java b/src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java new file mode 100644 index 000000000..153fc76a3 --- /dev/null +++ b/src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java @@ -0,0 +1,125 @@ +package eu.siacs.conversations.ui; + +import android.content.ComponentName; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.ServiceConnection; +import android.databinding.DataBindingUtil; +import android.databinding.ViewDataBinding; +import android.os.Bundle; +import android.os.IBinder; +import android.support.design.widget.Snackbar; +import android.support.v4.content.ContextCompat; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.Toolbar; +import android.util.Log; +import android.view.LayoutInflater; +import android.widget.Toast; + +import java.util.List; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.databinding.ActivityImportBackupBinding; +import eu.siacs.conversations.databinding.DialogEnterPasswordBinding; +import eu.siacs.conversations.services.ImportBackupService; +import eu.siacs.conversations.ui.adapter.BackupFileAdapter; + +public class ImportBackupActivity extends ActionBarActivity implements ServiceConnection, ImportBackupService.OnBackupFilesLoaded, BackupFileAdapter.OnItemClickedListener, ImportBackupService.OnBackupProcessed { + + private ActivityImportBackupBinding binding; + + private BackupFileAdapter backupFileAdapter; + private ImportBackupService service; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = DataBindingUtil.setContentView(this, R.layout.activity_import_backup); + setSupportActionBar((Toolbar) binding.toolbar); + configureActionBar(getSupportActionBar()); + this.backupFileAdapter = new BackupFileAdapter(); + this.binding.list.setAdapter(this.backupFileAdapter); + this.backupFileAdapter.setOnItemClickedListener(this); + } + + @Override + public void onStart() { + super.onStart(); + bindService(new Intent(this, ImportBackupService.class), this, Context.BIND_AUTO_CREATE); + } + + @Override + public void onStop() { + super.onStop(); + if (this.service != null) { + this.service.removeOnBackupProcessedListener(this); + } + unbindService(this); + } + + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + ImportBackupService.ImportBackupServiceBinder binder = (ImportBackupService.ImportBackupServiceBinder) service; + this.service = binder.getService(); + this.service.addOnBackupProcessedListener(this); + this.service.loadBackupFiles(this); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + this.service = null; + } + + @Override + public void onBackupFilesLoaded(final List files) { + runOnUiThread(() -> { + backupFileAdapter.setFiles(files); + }); + } + + @Override + public void onClick(ImportBackupService.BackupFile backupFile) { + final DialogEnterPasswordBinding enterPasswordBinding = DataBindingUtil.inflate(LayoutInflater.from(this), R.layout.dialog_enter_password, null, false); + Log.d(Config.LOGTAG, "attempting to import " + backupFile.getFile().getAbsolutePath()); + enterPasswordBinding.explain.setText(getString(R.string.enter_password_to_restore, backupFile.getHeader().getJid().toString())); + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setView(enterPasswordBinding.getRoot()); + builder.setTitle(R.string.enter_password); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.restore, (dialog, which) -> { + final String password = enterPasswordBinding.accountPassword.getEditableText().toString(); + Intent intent = new Intent(this, ImportBackupService.class); + intent.putExtra("password", password); + intent.putExtra("file", backupFile.getFile().getAbsolutePath()); + ContextCompat.startForegroundService(this, intent); + }); + builder.setCancelable(false); + builder.create().show(); + } + + @Override + public void onBackupRestored() { + runOnUiThread(() -> { + Intent intent = new Intent(this, ConversationActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + finish(); + }); + } + + @Override + public void onBackupDecryptionFailed() { + runOnUiThread(()-> { + Snackbar.make(binding.coordinator,R.string.unable_to_decrypt_backup,Snackbar.LENGTH_LONG).show(); + }); + } + + @Override + public void onBackupRestoreFailed() { + runOnUiThread(()-> { + Snackbar.make(binding.coordinator,R.string.unable_to_restore_backup,Snackbar.LENGTH_LONG).show(); + }); + } +} diff --git a/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java b/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java index e1330fe88..c0d2d7eff 100644 --- a/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java +++ b/src/conversations/java/eu/siacs/conversations/ui/ManageAccountActivity.java @@ -37,362 +37,364 @@ import rocks.xmpp.addr.Jid; public class ManageAccountActivity extends XmppActivity implements OnAccountUpdate, KeyChainAliasCallback, XmppConnectionService.OnAccountCreated, AccountAdapter.OnTglAccountState { - private final String STATE_SELECTED_ACCOUNT = "selected_account"; + private final String STATE_SELECTED_ACCOUNT = "selected_account"; - protected Account selectedAccount = null; - protected Jid selectedAccountJid = null; + protected Account selectedAccount = null; + protected Jid selectedAccountJid = null; - protected final List accountList = new ArrayList<>(); - protected ListView accountListView; - protected AccountAdapter mAccountAdapter; - protected AtomicBoolean mInvokedAddAccount = new AtomicBoolean(false); + protected final List accountList = new ArrayList<>(); + protected ListView accountListView; + protected AccountAdapter mAccountAdapter; + protected AtomicBoolean mInvokedAddAccount = new AtomicBoolean(false); - protected Pair mPostponedActivityResult = null; + protected Pair mPostponedActivityResult = null; - @Override - public void onAccountUpdate() { - refreshUi(); - } + @Override + public void onAccountUpdate() { + refreshUi(); + } - @Override - protected void refreshUiReal() { - synchronized (this.accountList) { - accountList.clear(); - accountList.addAll(xmppConnectionService.getAccounts()); - } - ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setHomeButtonEnabled(this.accountList.size() > 0); - actionBar.setDisplayHomeAsUpEnabled(this.accountList.size() > 0); - } - invalidateOptionsMenu(); - mAccountAdapter.notifyDataSetChanged(); - } + @Override + protected void refreshUiReal() { + synchronized (this.accountList) { + accountList.clear(); + accountList.addAll(xmppConnectionService.getAccounts()); + } + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setHomeButtonEnabled(this.accountList.size() > 0); + actionBar.setDisplayHomeAsUpEnabled(this.accountList.size() > 0); + } + invalidateOptionsMenu(); + mAccountAdapter.notifyDataSetChanged(); + } - @Override - protected void onCreate(Bundle savedInstanceState) { + @Override + protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + super.onCreate(savedInstanceState); - setContentView(R.layout.activity_manage_accounts); - setSupportActionBar(findViewById(R.id.toolbar)); - configureActionBar(getSupportActionBar()); - if (savedInstanceState != null) { - String jid = savedInstanceState.getString(STATE_SELECTED_ACCOUNT); - if (jid != null) { - try { - this.selectedAccountJid = Jid.of(jid); - } catch (IllegalArgumentException e) { - this.selectedAccountJid = null; - } - } - } + setContentView(R.layout.activity_manage_accounts); + setSupportActionBar(findViewById(R.id.toolbar)); + configureActionBar(getSupportActionBar()); + if (savedInstanceState != null) { + String jid = savedInstanceState.getString(STATE_SELECTED_ACCOUNT); + if (jid != null) { + try { + this.selectedAccountJid = Jid.of(jid); + } catch (IllegalArgumentException e) { + this.selectedAccountJid = null; + } + } + } - accountListView = findViewById(R.id.account_list); - this.mAccountAdapter = new AccountAdapter(this, accountList); - accountListView.setAdapter(this.mAccountAdapter); - accountListView.setOnItemClickListener((arg0, view, position, arg3) -> switchToAccount(accountList.get(position))); - registerForContextMenu(accountListView); - } + accountListView = findViewById(R.id.account_list); + this.mAccountAdapter = new AccountAdapter(this, accountList); + accountListView.setAdapter(this.mAccountAdapter); + accountListView.setOnItemClickListener((arg0, view, position, arg3) -> switchToAccount(accountList.get(position))); + registerForContextMenu(accountListView); + } - @Override - protected void onStart() { - super.onStart(); - final int theme = findTheme(); - if (this.mTheme != theme) { - recreate(); - } - } + @Override + protected void onStart() { + super.onStart(); + final int theme = findTheme(); + if (this.mTheme != theme) { + recreate(); + } + } - @Override - public void onSaveInstanceState(final Bundle savedInstanceState) { - if (selectedAccount != null) { - savedInstanceState.putString(STATE_SELECTED_ACCOUNT, selectedAccount.getJid().asBareJid().toString()); - } - super.onSaveInstanceState(savedInstanceState); - } + @Override + public void onSaveInstanceState(final Bundle savedInstanceState) { + if (selectedAccount != null) { + savedInstanceState.putString(STATE_SELECTED_ACCOUNT, selectedAccount.getJid().asBareJid().toString()); + } + super.onSaveInstanceState(savedInstanceState); + } - @Override - public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { - super.onCreateContextMenu(menu, v, menuInfo); - ManageAccountActivity.this.getMenuInflater().inflate( - R.menu.manageaccounts_context, menu); - AdapterContextMenuInfo acmi = (AdapterContextMenuInfo) menuInfo; - this.selectedAccount = accountList.get(acmi.position); - if (this.selectedAccount.isEnabled()) { - menu.findItem(R.id.mgmt_account_enable).setVisible(false); - menu.findItem(R.id.mgmt_account_announce_pgp).setVisible(Config.supportOpenPgp()); - } else { - menu.findItem(R.id.mgmt_account_disable).setVisible(false); - menu.findItem(R.id.mgmt_account_announce_pgp).setVisible(false); - menu.findItem(R.id.mgmt_account_publish_avatar).setVisible(false); - } - menu.setHeaderTitle(this.selectedAccount.getJid().asBareJid().toString()); - } + @Override + public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, v, menuInfo); + ManageAccountActivity.this.getMenuInflater().inflate( + R.menu.manageaccounts_context, menu); + AdapterContextMenuInfo acmi = (AdapterContextMenuInfo) menuInfo; + this.selectedAccount = accountList.get(acmi.position); + if (this.selectedAccount.isEnabled()) { + menu.findItem(R.id.mgmt_account_enable).setVisible(false); + menu.findItem(R.id.mgmt_account_announce_pgp).setVisible(Config.supportOpenPgp()); + } else { + menu.findItem(R.id.mgmt_account_disable).setVisible(false); + menu.findItem(R.id.mgmt_account_announce_pgp).setVisible(false); + menu.findItem(R.id.mgmt_account_publish_avatar).setVisible(false); + } + menu.setHeaderTitle(this.selectedAccount.getJid().asBareJid().toString()); + } - @Override - void onBackendConnected() { - if (selectedAccountJid != null) { - this.selectedAccount = xmppConnectionService.findAccountByJid(selectedAccountJid); - } - refreshUiReal(); - if (this.mPostponedActivityResult != null) { - this.onActivityResult(mPostponedActivityResult.first, RESULT_OK, mPostponedActivityResult.second); - } - if (Config.X509_VERIFICATION && this.accountList.size() == 0) { - if (mInvokedAddAccount.compareAndSet(false, true)) { - addAccountFromKey(); - } - } - } + @Override + void onBackendConnected() { + if (selectedAccountJid != null) { + this.selectedAccount = xmppConnectionService.findAccountByJid(selectedAccountJid); + } + refreshUiReal(); + if (this.mPostponedActivityResult != null) { + this.onActivityResult(mPostponedActivityResult.first, RESULT_OK, mPostponedActivityResult.second); + } + if (Config.X509_VERIFICATION && this.accountList.size() == 0) { + if (mInvokedAddAccount.compareAndSet(false, true)) { + addAccountFromKey(); + } + } + } - @Override - public boolean onCreateOptionsMenu(Menu menu) { - getMenuInflater().inflate(R.menu.manageaccounts, menu); - MenuItem enableAll = menu.findItem(R.id.action_enable_all); - MenuItem addAccount = menu.findItem(R.id.action_add_account); - MenuItem addAccountWithCertificate = menu.findItem(R.id.action_add_account_with_cert); + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.manageaccounts, menu); + MenuItem enableAll = menu.findItem(R.id.action_enable_all); + MenuItem addAccount = menu.findItem(R.id.action_add_account); + MenuItem addAccountWithCertificate = menu.findItem(R.id.action_add_account_with_cert); - if (Config.X509_VERIFICATION) { - addAccount.setVisible(false); - addAccountWithCertificate.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); - } + if (Config.X509_VERIFICATION) { + addAccount.setVisible(false); + addAccountWithCertificate.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + } - if (!accountsLeftToEnable()) { - enableAll.setVisible(false); - } - MenuItem disableAll = menu.findItem(R.id.action_disable_all); - if (!accountsLeftToDisable()) { - disableAll.setVisible(false); - } - return true; - } + if (!accountsLeftToEnable()) { + enableAll.setVisible(false); + } + MenuItem disableAll = menu.findItem(R.id.action_disable_all); + if (!accountsLeftToDisable()) { + disableAll.setVisible(false); + } + return true; + } - @Override - public boolean onContextItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.mgmt_account_publish_avatar: - publishAvatar(selectedAccount); - return true; - case R.id.mgmt_account_disable: - disableAccount(selectedAccount); - return true; - case R.id.mgmt_account_enable: - enableAccount(selectedAccount); - return true; - case R.id.mgmt_account_delete: - deleteAccount(selectedAccount); - return true; - case R.id.mgmt_account_announce_pgp: - publishOpenPGPPublicKey(selectedAccount); - return true; - default: - return super.onContextItemSelected(item); - } - } + @Override + public boolean onContextItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.mgmt_account_publish_avatar: + publishAvatar(selectedAccount); + return true; + case R.id.mgmt_account_disable: + disableAccount(selectedAccount); + return true; + case R.id.mgmt_account_enable: + enableAccount(selectedAccount); + return true; + case R.id.mgmt_account_delete: + deleteAccount(selectedAccount); + return true; + case R.id.mgmt_account_announce_pgp: + publishOpenPGPPublicKey(selectedAccount); + return true; + default: + return super.onContextItemSelected(item); + } + } - @Override - public boolean onOptionsItemSelected(MenuItem item) { - if (MenuDoubleTabUtil.shouldIgnoreTap()) { - return false; - } - switch (item.getItemId()) { - case R.id.action_add_account: - startActivity(new Intent(getApplicationContext(), - EditAccountActivity.class)); - break; - case R.id.action_disable_all: - disableAllAccounts(); - break; - case R.id.action_enable_all: - enableAllAccounts(); - break; - case R.id.action_add_account_with_cert: - addAccountFromKey(); - break; - default: - break; - } - return super.onOptionsItemSelected(item); - } + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (MenuDoubleTabUtil.shouldIgnoreTap()) { + return false; + } + switch (item.getItemId()) { + case R.id.action_add_account: + startActivity(new Intent(this, EditAccountActivity.class)); + break; + case R.id.action_import_backup: + startActivity(new Intent(this, ImportBackupActivity.class)); + break; + case R.id.action_disable_all: + disableAllAccounts(); + break; + case R.id.action_enable_all: + enableAllAccounts(); + break; + case R.id.action_add_account_with_cert: + addAccountFromKey(); + break; + default: + break; + } + return super.onOptionsItemSelected(item); + } - @Override - public boolean onNavigateUp() { - if (xmppConnectionService.getConversations().size() == 0) { - Intent contactsIntent = new Intent(this, - StartConversationActivity.class); - contactsIntent.setFlags( - // if activity exists in stack, pop the stack and go back to it - Intent.FLAG_ACTIVITY_CLEAR_TOP | - // otherwise, make a new task for it - Intent.FLAG_ACTIVITY_NEW_TASK | - // don't use the new activity animation; finish - // animation runs instead - Intent.FLAG_ACTIVITY_NO_ANIMATION); - startActivity(contactsIntent); - finish(); - return true; - } else { - return super.onNavigateUp(); - } - } + @Override + public boolean onNavigateUp() { + if (xmppConnectionService.getConversations().size() == 0) { + Intent contactsIntent = new Intent(this, + StartConversationActivity.class); + contactsIntent.setFlags( + // if activity exists in stack, pop the stack and go back to it + Intent.FLAG_ACTIVITY_CLEAR_TOP | + // otherwise, make a new task for it + Intent.FLAG_ACTIVITY_NEW_TASK | + // don't use the new activity animation; finish + // animation runs instead + Intent.FLAG_ACTIVITY_NO_ANIMATION); + startActivity(contactsIntent); + finish(); + return true; + } else { + return super.onNavigateUp(); + } + } - @Override - public void onClickTglAccountState(Account account, boolean enable) { - if (enable) { - enableAccount(account); - } else { - disableAccount(account); - } - } + @Override + public void onClickTglAccountState(Account account, boolean enable) { + if (enable) { + enableAccount(account); + } else { + disableAccount(account); + } + } - private void addAccountFromKey() { - try { - KeyChain.choosePrivateKeyAlias(this, this, null, null, null, -1, null); - } catch (ActivityNotFoundException e) { - Toast.makeText(this, R.string.device_does_not_support_certificates, Toast.LENGTH_LONG).show(); - } - } + private void addAccountFromKey() { + try { + KeyChain.choosePrivateKeyAlias(this, this, null, null, null, -1, null); + } catch (ActivityNotFoundException e) { + Toast.makeText(this, R.string.device_does_not_support_certificates, Toast.LENGTH_LONG).show(); + } + } - private void publishAvatar(Account account) { - Intent intent = new Intent(getApplicationContext(), - PublishProfilePictureActivity.class); - intent.putExtra(EXTRA_ACCOUNT, account.getJid().toString()); - startActivity(intent); - } + private void publishAvatar(Account account) { + Intent intent = new Intent(getApplicationContext(), + PublishProfilePictureActivity.class); + intent.putExtra(EXTRA_ACCOUNT, account.getJid().toString()); + startActivity(intent); + } - private void disableAllAccounts() { - List list = new ArrayList<>(); - synchronized (this.accountList) { - for (Account account : this.accountList) { - if (account.isEnabled()) { - list.add(account); - } - } - } - for (Account account : list) { - disableAccount(account); - } - } + private void disableAllAccounts() { + List list = new ArrayList<>(); + synchronized (this.accountList) { + for (Account account : this.accountList) { + if (account.isEnabled()) { + list.add(account); + } + } + } + for (Account account : list) { + disableAccount(account); + } + } - private boolean accountsLeftToDisable() { - synchronized (this.accountList) { - for (Account account : this.accountList) { - if (account.isEnabled()) { - return true; - } - } - return false; - } - } + private boolean accountsLeftToDisable() { + synchronized (this.accountList) { + for (Account account : this.accountList) { + if (account.isEnabled()) { + return true; + } + } + return false; + } + } - private boolean accountsLeftToEnable() { - synchronized (this.accountList) { - for (Account account : this.accountList) { - if (!account.isEnabled()) { - return true; - } - } - return false; - } - } + private boolean accountsLeftToEnable() { + synchronized (this.accountList) { + for (Account account : this.accountList) { + if (!account.isEnabled()) { + return true; + } + } + return false; + } + } - private void enableAllAccounts() { - List list = new ArrayList<>(); - synchronized (this.accountList) { - for (Account account : this.accountList) { - if (!account.isEnabled()) { - list.add(account); - } - } - } - for (Account account : list) { - enableAccount(account); - } - } + private void enableAllAccounts() { + List list = new ArrayList<>(); + synchronized (this.accountList) { + for (Account account : this.accountList) { + if (!account.isEnabled()) { + list.add(account); + } + } + } + for (Account account : list) { + enableAccount(account); + } + } - private void disableAccount(Account account) { - account.setOption(Account.OPTION_DISABLED, true); - if (!xmppConnectionService.updateAccount(account)) { - Toast.makeText(this,R.string.unable_to_update_account,Toast.LENGTH_SHORT).show(); - } - } + private void disableAccount(Account account) { + account.setOption(Account.OPTION_DISABLED, true); + if (!xmppConnectionService.updateAccount(account)) { + Toast.makeText(this, R.string.unable_to_update_account, Toast.LENGTH_SHORT).show(); + } + } - private void enableAccount(Account account) { - account.setOption(Account.OPTION_DISABLED, false); - final XmppConnection connection = account.getXmppConnection(); - if (connection != null) { - connection.resetEverything(); - } - if (!xmppConnectionService.updateAccount(account)) { - Toast.makeText(this,R.string.unable_to_update_account,Toast.LENGTH_SHORT).show(); - } - } + private void enableAccount(Account account) { + account.setOption(Account.OPTION_DISABLED, false); + final XmppConnection connection = account.getXmppConnection(); + if (connection != null) { + connection.resetEverything(); + } + if (!xmppConnectionService.updateAccount(account)) { + Toast.makeText(this, R.string.unable_to_update_account, Toast.LENGTH_SHORT).show(); + } + } - private void publishOpenPGPPublicKey(Account account) { - if (ManageAccountActivity.this.hasPgp()) { - announcePgp(selectedAccount, null,null, onOpenPGPKeyPublished); - } else { - this.showInstallPgpDialog(); - } - } + private void publishOpenPGPPublicKey(Account account) { + if (ManageAccountActivity.this.hasPgp()) { + announcePgp(selectedAccount, null, null, onOpenPGPKeyPublished); + } else { + this.showInstallPgpDialog(); + } + } - private void deleteAccount(final Account account) { - AlertDialog.Builder builder = new AlertDialog.Builder(this); - builder.setTitle(getString(R.string.mgmt_account_are_you_sure)); - builder.setIconAttribute(android.R.attr.alertDialogIcon); - builder.setMessage(getString(R.string.mgmt_account_delete_confirm_text)); - builder.setPositiveButton(getString(R.string.delete), - (dialog, which) -> { - xmppConnectionService.deleteAccount(account); - selectedAccount = null; - if (xmppConnectionService.getAccounts().size() == 0 && Config.MAGIC_CREATE_DOMAIN != null) { - WelcomeActivity.launch(this); - } - }); - builder.setNegativeButton(getString(R.string.cancel), null); - builder.create().show(); - } + private void deleteAccount(final Account account) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(getString(R.string.mgmt_account_are_you_sure)); + builder.setIconAttribute(android.R.attr.alertDialogIcon); + builder.setMessage(getString(R.string.mgmt_account_delete_confirm_text)); + builder.setPositiveButton(getString(R.string.delete), + (dialog, which) -> { + xmppConnectionService.deleteAccount(account); + selectedAccount = null; + if (xmppConnectionService.getAccounts().size() == 0 && Config.MAGIC_CREATE_DOMAIN != null) { + WelcomeActivity.launch(this); + } + }); + builder.setNegativeButton(getString(R.string.cancel), null); + builder.create().show(); + } - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (resultCode == RESULT_OK) { - if (xmppConnectionServiceBound) { - if (requestCode == REQUEST_CHOOSE_PGP_ID) { - if (data.getExtras().containsKey(OpenPgpApi.EXTRA_SIGN_KEY_ID)) { - selectedAccount.setPgpSignId(data.getExtras().getLong(OpenPgpApi.EXTRA_SIGN_KEY_ID)); - announcePgp(selectedAccount, null, null, onOpenPGPKeyPublished); - } else { - choosePgpSignId(selectedAccount); - } - } else if (requestCode == REQUEST_ANNOUNCE_PGP) { - announcePgp(selectedAccount, null, data, onOpenPGPKeyPublished); - } - this.mPostponedActivityResult = null; - } else { - this.mPostponedActivityResult = new Pair<>(requestCode, data); - } - } - } + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == RESULT_OK) { + if (xmppConnectionServiceBound) { + if (requestCode == REQUEST_CHOOSE_PGP_ID) { + if (data.getExtras().containsKey(OpenPgpApi.EXTRA_SIGN_KEY_ID)) { + selectedAccount.setPgpSignId(data.getExtras().getLong(OpenPgpApi.EXTRA_SIGN_KEY_ID)); + announcePgp(selectedAccount, null, null, onOpenPGPKeyPublished); + } else { + choosePgpSignId(selectedAccount); + } + } else if (requestCode == REQUEST_ANNOUNCE_PGP) { + announcePgp(selectedAccount, null, data, onOpenPGPKeyPublished); + } + this.mPostponedActivityResult = null; + } else { + this.mPostponedActivityResult = new Pair<>(requestCode, data); + } + } + } - @Override - public void alias(String alias) { - if (alias != null) { - xmppConnectionService.createAccountFromKey(alias, this); - } - } + @Override + public void alias(String alias) { + if (alias != null) { + xmppConnectionService.createAccountFromKey(alias, this); + } + } - @Override - public void onAccountCreated(Account account) { - Intent intent = new Intent(this, EditAccountActivity.class); - intent.putExtra("jid", account.getJid().asBareJid().toString()); - intent.putExtra("init", true); - startActivity(intent); - } + @Override + public void onAccountCreated(Account account) { + Intent intent = new Intent(this, EditAccountActivity.class); + intent.putExtra("jid", account.getJid().asBareJid().toString()); + intent.putExtra("init", true); + startActivity(intent); + } - @Override - public void informUser(final int r) { - runOnUiThread(() -> Toast.makeText(ManageAccountActivity.this, r, Toast.LENGTH_LONG).show()); - } + @Override + public void informUser(final int r) { + runOnUiThread(() -> Toast.makeText(ManageAccountActivity.this, r, Toast.LENGTH_LONG).show()); + } } diff --git a/src/conversations/java/eu/siacs/conversations/ui/WelcomeActivity.java b/src/conversations/java/eu/siacs/conversations/ui/WelcomeActivity.java index 4eb614d55..332927550 100644 --- a/src/conversations/java/eu/siacs/conversations/ui/WelcomeActivity.java +++ b/src/conversations/java/eu/siacs/conversations/ui/WelcomeActivity.java @@ -3,89 +3,108 @@ package eu.siacs.conversations.ui; import android.content.Intent; import android.content.pm.ActivityInfo; import android.os.Bundle; +import android.support.v4.content.ContextCompat; import android.support.v7.app.ActionBar; import android.support.v7.app.AppCompatActivity; +import android.view.Menu; +import android.view.MenuItem; import android.widget.Button; import java.util.List; import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.services.ImportBackupService; import eu.siacs.conversations.utils.XmppUri; public class WelcomeActivity extends XmppActivity { - @Override - protected void refreshUiReal() { + @Override + protected void refreshUiReal() { - } + } - @Override - void onBackendConnected() { + @Override + void onBackendConnected() { - } + } - @Override - public void onStart() { - super.onStart(); - final int theme = findTheme(); - if (this.mTheme != theme) { - recreate(); - } - } + @Override + public void onStart() { + super.onStart(); + final int theme = findTheme(); + if (this.mTheme != theme) { + recreate(); + } + } - @Override - public void onNewIntent(Intent intent) { - if (intent != null) { - setIntent(intent); - } - } + @Override + public void onNewIntent(Intent intent) { + if (intent != null) { + setIntent(intent); + } + } - @Override - protected void onCreate(final Bundle savedInstanceState) { - if (getResources().getBoolean(R.bool.portrait_only)) { - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); - } - super.onCreate(savedInstanceState); - setContentView(R.layout.welcome); - setSupportActionBar(findViewById(R.id.toolbar)); - final ActionBar ab = getSupportActionBar(); - if (ab != null) { - ab.setDisplayShowHomeEnabled(false); - ab.setDisplayHomeAsUpEnabled(false); - } - final Button createAccount = findViewById(R.id.create_account); - createAccount.setOnClickListener(v -> { - final Intent intent = new Intent(WelcomeActivity.this, MagicCreateActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); - addInviteUri(intent); - startActivity(intent); - }); - final Button useOwnProvider = findViewById(R.id.use_own_provider); - useOwnProvider.setOnClickListener(v -> { - List accounts = xmppConnectionService.getAccounts(); - Intent intent = new Intent(WelcomeActivity.this, EditAccountActivity.class); - if (accounts.size() == 1) { - intent.putExtra("jid", accounts.get(0).getJid().asBareJid().toString()); - intent.putExtra("init", true); - } else if (accounts.size() >= 1) { - intent = new Intent(WelcomeActivity.this, ManageAccountActivity.class); - } - addInviteUri(intent); - startActivity(intent); - }); + @Override + protected void onCreate(final Bundle savedInstanceState) { + if (getResources().getBoolean(R.bool.portrait_only)) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + } + super.onCreate(savedInstanceState); + setContentView(R.layout.welcome); + setSupportActionBar(findViewById(R.id.toolbar)); + final ActionBar ab = getSupportActionBar(); + if (ab != null) { + ab.setDisplayShowHomeEnabled(false); + ab.setDisplayHomeAsUpEnabled(false); + } + final Button createAccount = findViewById(R.id.create_account); + createAccount.setOnClickListener(v -> { + final Intent intent = new Intent(WelcomeActivity.this, MagicCreateActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + addInviteUri(intent); + startActivity(intent); + }); + final Button useOwnProvider = findViewById(R.id.use_own_provider); + useOwnProvider.setOnClickListener(v -> { + List accounts = xmppConnectionService.getAccounts(); + Intent intent = new Intent(WelcomeActivity.this, EditAccountActivity.class); + if (accounts.size() == 1) { + intent.putExtra("jid", accounts.get(0).getJid().asBareJid().toString()); + intent.putExtra("init", true); + } else if (accounts.size() >= 1) { + intent = new Intent(WelcomeActivity.this, ManageAccountActivity.class); + } + addInviteUri(intent); + startActivity(intent); + }); - } + } - public void addInviteUri(Intent intent) { - StartConversationActivity.addInviteUri(intent, getIntent()); - } + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.welcome_menu, menu); + return super.onCreateOptionsMenu(menu); + } - public static void launch(AppCompatActivity activity) { - Intent intent = new Intent(activity, WelcomeActivity.class); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - activity.startActivity(intent); - activity.overridePendingTransition(0,0); - } + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.action_import_backup) { + startActivity(new Intent(this, ImportBackupActivity.class)); + return true; + } + return super.onOptionsItemSelected(item); + } + + public void addInviteUri(Intent intent) { + StartConversationActivity.addInviteUri(intent, getIntent()); + } + + public static void launch(AppCompatActivity activity) { + Intent intent = new Intent(activity, WelcomeActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + activity.startActivity(intent); + activity.overridePendingTransition(0, 0); + } } diff --git a/src/conversations/java/eu/siacs/conversations/ui/adapter/BackupFileAdapter.java b/src/conversations/java/eu/siacs/conversations/ui/adapter/BackupFileAdapter.java new file mode 100644 index 000000000..870340d7b --- /dev/null +++ b/src/conversations/java/eu/siacs/conversations/ui/adapter/BackupFileAdapter.java @@ -0,0 +1,169 @@ +package eu.siacs.conversations.ui.adapter; + +import android.content.res.Resources; +import android.databinding.DataBindingUtil; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.AsyncTask; +import android.support.annotation.NonNull; +import android.support.v7.widget.RecyclerView; +import android.text.format.DateUtils; +import android.util.DisplayMetrics; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.RejectedExecutionException; + +import eu.siacs.conversations.R; +import eu.siacs.conversations.databinding.AccountRowBinding; +import eu.siacs.conversations.services.AvatarService; +import eu.siacs.conversations.services.ImportBackupService; +import eu.siacs.conversations.utils.BackupFileHeader; +import eu.siacs.conversations.utils.UIHelper; +import rocks.xmpp.addr.Jid; + +public class BackupFileAdapter extends RecyclerView.Adapter { + + private OnItemClickedListener listener; + + private final List files = new ArrayList<>(); + + + @NonNull + @Override + public BackupFileViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { + return new BackupFileViewHolder(DataBindingUtil.inflate(LayoutInflater.from(viewGroup.getContext()), R.layout.account_row, viewGroup, false)); + } + + @Override + public void onBindViewHolder(@NonNull BackupFileViewHolder backupFileViewHolder, int position) { + final ImportBackupService.BackupFile backupFile = files.get(position); + final BackupFileHeader header = backupFile.getHeader(); + backupFileViewHolder.binding.accountJid.setText(header.getJid().asBareJid().toString()); + backupFileViewHolder.binding.accountStatus.setText(String.format("%s · %s",header.getApp(), DateUtils.formatDateTime(backupFileViewHolder.binding.getRoot().getContext(), header.getTimestamp(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR))); + backupFileViewHolder.binding.tglAccountStatus.setVisibility(View.GONE); + backupFileViewHolder.binding.getRoot().setOnClickListener(v -> { + if (listener != null) { + listener.onClick(backupFile); + } + }); + loadAvatar(header.getJid(), backupFileViewHolder.binding.accountImage); + } + + @Override + public int getItemCount() { + return files.size(); + } + + public void setFiles(List files) { + this.files.clear(); + this.files.addAll(files); + notifyDataSetChanged(); + } + + public void setOnItemClickedListener(OnItemClickedListener listener) { + this.listener = listener; + } + + static class BackupFileViewHolder extends RecyclerView.ViewHolder { + private final AccountRowBinding binding; + + BackupFileViewHolder(AccountRowBinding binding) { + super(binding.getRoot()); + this.binding = binding; + } + + } + + public interface OnItemClickedListener { + void onClick(ImportBackupService.BackupFile backupFile); + } + + static class BitmapWorkerTask extends AsyncTask { + private final WeakReference imageViewReference; + private Jid jid = null; + private final int size; + + BitmapWorkerTask(ImageView imageView) { + imageViewReference = new WeakReference<>(imageView); + DisplayMetrics metrics = imageView.getContext().getResources().getDisplayMetrics(); + this.size = ((int) (48 * metrics.density)); + } + + @Override + protected Bitmap doInBackground(Jid... params) { + this.jid = params[0]; + return AvatarService.get(this.jid, size); + } + + @Override + protected void onPostExecute(Bitmap bitmap) { + if (bitmap != null && !isCancelled()) { + final ImageView imageView = imageViewReference.get(); + if (imageView != null) { + imageView.setImageBitmap(bitmap); + imageView.setBackgroundColor(0x00000000); + } + } + } + } + + private void loadAvatar(Jid jid, ImageView imageView) { + if (cancelPotentialWork(jid, imageView)) { + imageView.setBackgroundColor(UIHelper.getColorForName(jid.asBareJid().toString())); + imageView.setImageDrawable(null); + final BitmapWorkerTask task = new BitmapWorkerTask(imageView); + final AsyncDrawable asyncDrawable = new AsyncDrawable(imageView.getContext().getResources(), null, task); + imageView.setImageDrawable(asyncDrawable); + try { + task.execute(jid); + } catch (final RejectedExecutionException ignored) { + } + } + } + + private static boolean cancelPotentialWork(Jid jid, ImageView imageView) { + final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); + + if (bitmapWorkerTask != null) { + final Jid oldJid = bitmapWorkerTask.jid; + if (oldJid == null || jid != oldJid) { + bitmapWorkerTask.cancel(true); + } else { + return false; + } + } + return true; + } + + private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) { + if (imageView != null) { + final Drawable drawable = imageView.getDrawable(); + if (drawable instanceof AsyncDrawable) { + final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable; + return asyncDrawable.getBitmapWorkerTask(); + } + } + return null; + } + + static class AsyncDrawable extends BitmapDrawable { + private final WeakReference bitmapWorkerTaskReference; + + AsyncDrawable(Resources res, Bitmap bitmap, BitmapWorkerTask bitmapWorkerTask) { + super(res, bitmap); + bitmapWorkerTaskReference = new WeakReference<>(bitmapWorkerTask); + } + + BitmapWorkerTask getBitmapWorkerTask() { + return bitmapWorkerTaskReference.get(); + } + } + +} \ No newline at end of file diff --git a/src/conversations/res/drawable-hdpi/ic_unarchive_white_24dp.png b/src/conversations/res/drawable-hdpi/ic_unarchive_white_24dp.png new file mode 100644 index 000000000..18730f12f Binary files /dev/null and b/src/conversations/res/drawable-hdpi/ic_unarchive_white_24dp.png differ diff --git a/src/conversations/res/drawable-mdpi/ic_unarchive_white_24dp.png b/src/conversations/res/drawable-mdpi/ic_unarchive_white_24dp.png new file mode 100644 index 000000000..8ec62cd34 Binary files /dev/null and b/src/conversations/res/drawable-mdpi/ic_unarchive_white_24dp.png differ diff --git a/src/conversations/res/drawable-xhdpi/ic_unarchive_white_24dp.png b/src/conversations/res/drawable-xhdpi/ic_unarchive_white_24dp.png new file mode 100644 index 000000000..a0a1509a1 Binary files /dev/null and b/src/conversations/res/drawable-xhdpi/ic_unarchive_white_24dp.png differ diff --git a/src/conversations/res/drawable-xxhdpi/ic_unarchive_white_24dp.png b/src/conversations/res/drawable-xxhdpi/ic_unarchive_white_24dp.png new file mode 100644 index 000000000..20d015751 Binary files /dev/null and b/src/conversations/res/drawable-xxhdpi/ic_unarchive_white_24dp.png differ diff --git a/src/conversations/res/drawable-xxxhdpi/ic_unarchive_white_24dp.png b/src/conversations/res/drawable-xxxhdpi/ic_unarchive_white_24dp.png new file mode 100644 index 000000000..a789520ba Binary files /dev/null and b/src/conversations/res/drawable-xxxhdpi/ic_unarchive_white_24dp.png differ diff --git a/src/conversations/res/layout/activity_import_backup.xml b/src/conversations/res/layout/activity_import_backup.xml new file mode 100644 index 000000000..bc5ccecc1 --- /dev/null +++ b/src/conversations/res/layout/activity_import_backup.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/conversations/res/layout/dialog_enter_password.xml b/src/conversations/res/layout/dialog_enter_password.xml new file mode 100644 index 000000000..e2fc38ff4 --- /dev/null +++ b/src/conversations/res/layout/dialog_enter_password.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/menu/manageaccounts.xml b/src/conversations/res/menu/manageaccounts.xml similarity index 88% rename from src/main/res/menu/manageaccounts.xml rename to src/conversations/res/menu/manageaccounts.xml index 724b9a75d..5a26beaf9 100644 --- a/src/main/res/menu/manageaccounts.xml +++ b/src/conversations/res/menu/manageaccounts.xml @@ -7,6 +7,10 @@ android:icon="?attr/icon_add_person" app:showAsAction="always" android:title="@string/action_add_account"/> + + + + \ No newline at end of file diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index b0623dd76..a1966a902 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -245,7 +245,8 @@ - + + diff --git a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java index eb84fb64a..2adec05ae 100644 --- a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java @@ -150,8 +150,12 @@ public class FileBackend { return Environment.getExternalStorageDirectory().getAbsolutePath() + "/" + context.getString(R.string.app_name) + "/Media/"; } - public static String getConversationsLogsDirectory() { - return Environment.getExternalStorageDirectory().getAbsolutePath() + "/Conversations/"; + public static String getBackupDirectory(Context context) { + return getBackupDirectory(context.getString(R.string.app_name)); + } + + public static String getBackupDirectory(String app) { + return Environment.getExternalStorageDirectory().getAbsolutePath() + "/"+app+"/Backup/"; } private static Bitmap rotate(Bitmap bitmap, int degree) { diff --git a/src/main/java/eu/siacs/conversations/services/AvatarService.java b/src/main/java/eu/siacs/conversations/services/AvatarService.java index bf37dca4f..5dc6996c7 100644 --- a/src/main/java/eu/siacs/conversations/services/AvatarService.java +++ b/src/main/java/eu/siacs/conversations/services/AvatarService.java @@ -511,7 +511,11 @@ public class AvatarService implements OnAdvancedStreamFeaturesLoaded { return bitmap; } - private Bitmap getImpl(final String name, final String seed, final int size) { + public static Bitmap get(final Jid jid, final int size) { + return getImpl(jid.asBareJid().toEscapedString(), null, size); + } + + private static Bitmap getImpl(final String name, final String seed, final int size) { Bitmap bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); final String trimmedName = name == null ? "" : name.trim(); @@ -528,7 +532,7 @@ public class AvatarService implements OnAdvancedStreamFeaturesLoaded { return PREFIX_GENERIC + "_" + name + "_" + String.valueOf(size); } - private boolean drawTile(Canvas canvas, String letter, int tileColor, int left, int top, int right, int bottom) { + private static boolean drawTile(Canvas canvas, String letter, int tileColor, int left, int top, int right, int bottom) { letter = letter.toUpperCase(Locale.getDefault()); Paint tilePaint = new Paint(), textPaint = new Paint(); tilePaint.setColor(tileColor); @@ -591,7 +595,7 @@ public class AvatarService implements OnAdvancedStreamFeaturesLoaded { return drawTile(canvas, name, name, left, top, right, bottom); } - private boolean drawTile(Canvas canvas, String name, String seed, int left, int top, int right, int bottom) { + private static boolean drawTile(Canvas canvas, String name, String seed, int left, int top, int right, int bottom) { if (name != null) { final String letter = getFirstLetter(name); final int color = UIHelper.getColorForName(seed == null ? name : seed); diff --git a/src/main/java/eu/siacs/conversations/services/ExportBackupService.java b/src/main/java/eu/siacs/conversations/services/ExportBackupService.java new file mode 100644 index 000000000..63fc66e51 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/services/ExportBackupService.java @@ -0,0 +1,281 @@ +package eu.siacs.conversations.services; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.os.IBinder; +import android.support.v4.app.NotificationCompat; +import android.util.Log; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.PrintWriter; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.zip.GZIPOutputStream; + +import javax.crypto.Cipher; +import javax.crypto.CipherOutputStream; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore; +import eu.siacs.conversations.entities.Account; +import eu.siacs.conversations.entities.Conversation; +import eu.siacs.conversations.entities.Message; +import eu.siacs.conversations.persistance.DatabaseBackend; +import eu.siacs.conversations.persistance.FileBackend; +import eu.siacs.conversations.utils.BackupFileHeader; +import eu.siacs.conversations.utils.Compatibility; + +public class ExportBackupService extends Service { + + public static final String KEYTYPE = "AES"; + public static final String CIPHERMODE = "AES/GCM/NoPadding"; + public static final String PROVIDER = "BC"; + + private static final int NOTIFICATION_ID = 19; + private static AtomicBoolean running = new AtomicBoolean(false); + private DatabaseBackend mDatabaseBackend; + private List mAccounts; + private NotificationManager notificationManager; + + @Override + public void onCreate() { + mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext()); + mAccounts = mDatabaseBackend.getAccounts(); + notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (running.compareAndSet(false, true)) { + new Thread(() -> { + export(); + stopForeground(true); + running.set(false); + stopSelf(); + }).start(); + } + return START_NOT_STICKY; + } + + private static void accountExport(SQLiteDatabase db, String uuid, PrintWriter writer) { + StringBuilder builder = new StringBuilder(); + final Cursor accountCursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", new String[]{uuid}, null, null, null); + while (accountCursor != null && accountCursor.moveToNext()) { + builder.append("INSERT INTO ").append(Account.TABLENAME).append("("); + for (int i = 0; i < accountCursor.getColumnCount(); ++i) { + if (i != 0) { + builder.append(','); + } + builder.append(accountCursor.getColumnName(i)); + } + builder.append(") VALUES("); + for (int i = 0; i < accountCursor.getColumnCount(); ++i) { + if (i != 0) { + builder.append(','); + } + final String value = accountCursor.getString(i); + if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) { + builder.append("NULL"); + } else if (value.matches("\\d+")) { + int intValue = Integer.parseInt(value); + Log.d(Config.LOGTAG,"reading int value. "+intValue); + if (Account.OPTIONS.equals(accountCursor.getColumnName(i))) { + intValue |= 1 << Account.OPTION_DISABLED; + Log.d(Config.LOGTAG,"modified int value "+intValue); + } + builder.append(intValue); + } else { + DatabaseUtils.appendEscapedSQLString(builder, value); + } + } + builder.append(")"); + builder.append(';'); + builder.append('\n'); + } + Log.d(Config.LOGTAG,builder.toString()); + if (accountCursor != null) { + accountCursor.close(); + } + writer.append(builder.toString()); + } + + private void messageExport(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) { + Cursor cursor = db.rawQuery("select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", new String[]{uuid}); + int size = cursor != null ? cursor.getCount() : 0; + Log.d(Config.LOGTAG, "exporting " + size + " messages"); + int i = 0; + int p = 0; + while (cursor != null && cursor.moveToNext()) { + writer.write(cursorToString(Message.TABLENAME, cursor, 20)); + if (i + 20 > size) { + i = size; + } else { + i += 20; + } + final int percentage = i * 100 / size; + if (p < percentage) { + p = percentage; + notificationManager.notify(NOTIFICATION_ID,progress.build(p)); + Log.d(Config.LOGTAG, "percentage=" + p); + } + } + if (cursor != null) { + cursor.close(); + } + } + + private static void simpleExport(SQLiteDatabase db, String table, String column, String uuid, PrintWriter writer) { + final Cursor cursor = db.query(table, null, column + "=?", new String[]{uuid}, null, null, null); + while (cursor != null && cursor.moveToNext()) { + writer.write(cursorToString(table, cursor, 20)); + } + if (cursor != null) { + cursor.close(); + } + } + + private void export() { + NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup"); + mBuilder.setContentTitle(getString(R.string.notification_create_backup_title)) + .setSmallIcon(R.drawable.ic_archive_white_24dp) + .setProgress(1, 0, false); + startForeground(NOTIFICATION_ID, mBuilder.build()); + try { + int count = 0; + final int max = this.mAccounts.size(); + final SecureRandom secureRandom = new SecureRandom(); + for (Account account : this.mAccounts) { + final byte[] IV = new byte[12]; + final byte[] salt = new byte[16]; + secureRandom.nextBytes(IV); + secureRandom.nextBytes(salt); + final BackupFileHeader backupFileHeader = new BackupFileHeader(getString(R.string.app_name),account.getJid(),System.currentTimeMillis(),IV,salt); + final Progress progress = new Progress(mBuilder, max, count); + final File file = new File(FileBackend.getBackupDirectory(this)+account.getJid().asBareJid().toEscapedString()+".ceb"); + if (file.getParentFile().mkdirs()) { + Log.d(Config.LOGTAG,"created backup directory "+file.getParentFile().getAbsolutePath()); + } + final FileOutputStream fileOutputStream = new FileOutputStream(file); + final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream); + backupFileHeader.write(dataOutputStream); + dataOutputStream.flush(); + + final Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER); + byte[] key = getKey(account.getPassword(), salt); + Log.d(Config.LOGTAG,backupFileHeader.toString()); + SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE); + IvParameterSpec ivSpec = new IvParameterSpec(IV); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); + CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher); + + GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream); + PrintWriter writer = new PrintWriter(gzipOutputStream); + SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase(); + final String uuid = account.getUuid(); + accountExport(db, uuid, writer); + simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, writer); + messageExport(db, uuid, writer, progress); + for(String table : Arrays.asList(SQLiteAxolotlStore.PREKEY_TABLENAME, SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME, SQLiteAxolotlStore.SESSION_TABLENAME, SQLiteAxolotlStore.IDENTITIES_TABLENAME)) { + simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT,uuid,writer); + } + writer.flush(); + writer.close(); + Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile()); + count++; + } + } catch (Exception e) { + Log.d(Config.LOGTAG, "unable to create backup ", e); + } + } + + public static byte[] getKey(String password, byte[] salt) { + try { + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)).getEncoded(); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new AssertionError(e); + } + } + + private static String cursorToString(String tablename, Cursor cursor, int max) { + StringBuilder builder = new StringBuilder(); + builder.append("INSERT INTO ").append(tablename).append("("); + for (int i = 0; i < cursor.getColumnCount(); ++i) { + if (i != 0) { + builder.append(','); + } + builder.append(cursor.getColumnName(i)); + } + builder.append(") VALUES"); + for (int i = 0; i < max; ++i) { + if (i != 0) { + builder.append(','); + } + appendValues(cursor, builder); + if (!cursor.moveToNext()) { + break; + } + } + builder.append(';'); + builder.append('\n'); + return builder.toString(); + } + + private static void appendValues(Cursor cursor, StringBuilder builder) { + builder.append("("); + for (int i = 0; i < cursor.getColumnCount(); ++i) { + if (i != 0) { + builder.append(','); + } + final String value = cursor.getString(i); + if (value == null) { + builder.append("NULL"); + } else if (value.matches("\\d+")) { + builder.append(value); + } else { + DatabaseUtils.appendEscapedSQLString(builder, value); + } + } + builder.append(")"); + + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private class Progress { + private final NotificationCompat.Builder builder; + private final int max; + private final int count; + + private Progress(NotificationCompat.Builder builder, int max, int count) { + this.builder = builder; + this.max = max; + this.count = count; + } + + private Notification build(int percentage) { + builder.setProgress(max * 100,count * 100 + percentage,false); + return builder.build(); + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/services/ExportLogsService.java b/src/main/java/eu/siacs/conversations/services/ExportLogsService.java deleted file mode 100644 index 79ed84496..000000000 --- a/src/main/java/eu/siacs/conversations/services/ExportLogsService.java +++ /dev/null @@ -1,148 +0,0 @@ -package eu.siacs.conversations.services; - -import android.app.NotificationManager; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.IBinder; -import android.support.v4.app.NotificationCompat; - -import java.io.BufferedWriter; -import java.io.File; -import java.io.FileWriter; -import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; - -import eu.siacs.conversations.R; -import eu.siacs.conversations.entities.Account; -import eu.siacs.conversations.entities.Conversation; -import eu.siacs.conversations.entities.Message; -import eu.siacs.conversations.persistance.DatabaseBackend; -import eu.siacs.conversations.persistance.FileBackend; -import rocks.xmpp.addr.Jid; - -public class ExportLogsService extends Service { - - private static final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); - private static final String DIRECTORY_STRING_FORMAT = FileBackend.getConversationsLogsDirectory() + "/logs/%s"; - private static final String MESSAGE_STRING_FORMAT = "(%s) %s: %s\n"; - private static final int NOTIFICATION_ID = 1; - private static AtomicBoolean running = new AtomicBoolean(false); - private DatabaseBackend mDatabaseBackend; - private List mAccounts; - - @Override - public void onCreate() { - mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext()); - mAccounts = mDatabaseBackend.getAccounts(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (running.compareAndSet(false, true)) { - new Thread(() -> { - export(); - stopForeground(true); - running.set(false); - stopSelf(); - }).start(); - } - return START_NOT_STICKY; - } - - private void export() { - List conversations = mDatabaseBackend.getConversations(Conversation.STATUS_AVAILABLE); - conversations.addAll(mDatabaseBackend.getConversations(Conversation.STATUS_ARCHIVED)); - NotificationManager mNotifyManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "export"); - mBuilder.setContentTitle(getString(R.string.notification_export_logs_title)) - .setSmallIcon(R.drawable.ic_import_export_white_24dp) - .setProgress(conversations.size(), 0, false); - startForeground(NOTIFICATION_ID, mBuilder.build()); - - int progress = 0; - for (Conversation conversation : conversations) { - writeToFile(conversation); - progress++; - mBuilder.setProgress(conversations.size(), progress, false); - if (mNotifyManager != null) { - mNotifyManager.notify(NOTIFICATION_ID, mBuilder.build()); - } - } - } - - private void writeToFile(Conversation conversation) { - Jid accountJid = resolveAccountUuid(conversation.getAccountUuid()); - Jid contactJid = conversation.getJid(); - - File dir = new File(String.format(DIRECTORY_STRING_FORMAT, accountJid.asBareJid().toString())); - dir.mkdirs(); - - BufferedWriter bw = null; - try { - for (Message message : mDatabaseBackend.getMessagesIterable(conversation)) { - if (message == null) - continue; - if (message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost()) { - String date = simpleDateFormat.format(new Date(message.getTimeSent())); - if (bw == null) { - bw = new BufferedWriter(new FileWriter( - new File(dir, contactJid.asBareJid().toString() + ".txt"))); - } - String jid = null; - switch (message.getStatus()) { - case Message.STATUS_RECEIVED: - jid = getMessageCounterpart(message); - break; - case Message.STATUS_SEND: - case Message.STATUS_SEND_RECEIVED: - case Message.STATUS_SEND_DISPLAYED: - jid = accountJid.asBareJid().toString(); - break; - } - if (jid != null) { - String body = message.hasFileOnRemoteHost() ? message.getFileParams().url.toString() : message.getBody(); - bw.write(String.format(MESSAGE_STRING_FORMAT, date, jid, - body.replace("\\\n", "\\ \n").replace("\n", "\\ \n"))); - } - } - } - } catch (IOException e) { - e.printStackTrace(); - } finally { - try { - if (bw != null) { - bw.close(); - } - } catch (IOException e1) { - e1.printStackTrace(); - } - } - } - - private Jid resolveAccountUuid(String accountUuid) { - for (Account account : mAccounts) { - if (account.getUuid().equals(accountUuid)) { - return account.getJid(); - } - } - return null; - } - - private String getMessageCounterpart(Message message) { - String trueCounterpart = (String) message.getContentValues().get(Message.TRUE_COUNTERPART); - if (trueCounterpart != null) { - return trueCounterpart; - } else { - return message.getCounterpart().toString(); - } - } - - @Override - public IBinder onBind(Intent intent) { - return null; - } -} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/services/NotificationService.java b/src/main/java/eu/siacs/conversations/services/NotificationService.java index e7fb1ba68..e2a2ba673 100644 --- a/src/main/java/eu/siacs/conversations/services/NotificationService.java +++ b/src/main/java/eu/siacs/conversations/services/NotificationService.java @@ -112,6 +112,8 @@ public class NotificationService { return; } + notificationManager.deleteNotificationChannel("export"); + notificationManager.createNotificationChannelGroup(new NotificationChannelGroup("status", c.getString(R.string.notification_group_status_information))); notificationManager.createNotificationChannelGroup(new NotificationChannelGroup("chats", c.getString(R.string.notification_group_messages))); final NotificationChannel foregroundServiceChannel = new NotificationChannel("foreground", @@ -136,8 +138,8 @@ public class NotificationService { videoCompressionChannel.setGroup("status"); notificationManager.createNotificationChannel(videoCompressionChannel); - final NotificationChannel exportChannel = new NotificationChannel("export", - c.getString(R.string.export_channel_name), + final NotificationChannel exportChannel = new NotificationChannel("backup", + c.getString(R.string.backup_channel_name), NotificationManager.IMPORTANCE_LOW); exportChannel.setShowBadge(false); exportChannel.setGroup("status"); diff --git a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java b/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java index 43fc763ad..73d7bd38a 100644 --- a/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/SettingsActivity.java @@ -21,8 +21,6 @@ import android.preference.PreferenceManager; import android.preference.PreferenceScreen; import android.provider.MediaStore; import android.util.Log; -import android.view.View; -import android.view.ViewGroup; import android.widget.Toast; import java.io.File; @@ -36,7 +34,8 @@ import eu.siacs.conversations.Config; import eu.siacs.conversations.R; import eu.siacs.conversations.crypto.OmemoSetting; import eu.siacs.conversations.entities.Account; -import eu.siacs.conversations.services.ExportLogsService; +import eu.siacs.conversations.persistance.FileBackend; +import eu.siacs.conversations.services.ExportBackupService; import eu.siacs.conversations.services.MemorizingTrustManager; import eu.siacs.conversations.services.QuickConversationsService; import eu.siacs.conversations.ui.util.StyledAttributes; @@ -59,7 +58,7 @@ public class SettingsActivity extends XmppActivity implements public static final String SHOW_DYNAMIC_TAGS = "show_dynamic_tags"; public static final String OMEMO_SETTING = "omemo"; - public static final int REQUEST_WRITE_LOGS = 0xbf8701; + public static final int REQUEST_CREATE_BACKUP = 0xbf8701; private SettingsFragment mSettingsFragment; @Override @@ -219,11 +218,12 @@ public class SettingsActivity extends XmppActivity implements }); } - final Preference exportLogsPreference = mSettingsFragment.findPreference("export_logs"); - if (exportLogsPreference != null) { - exportLogsPreference.setOnPreferenceClickListener(preference -> { - if (hasStoragePermission(REQUEST_WRITE_LOGS)) { - startExport(); + final Preference createBackupPreference = mSettingsFragment.findPreference("create_backup"); + if (createBackupPreference != null) { + createBackupPreference.setSummary(getString(R.string.pref_create_backup_summary, FileBackend.getBackupDirectory(this))); + createBackupPreference.setOnPreferenceClickListener(preference -> { + if (hasStoragePermission(REQUEST_CREATE_BACKUP)) { + createBackup(); } return true; }); @@ -399,16 +399,16 @@ public class SettingsActivity extends XmppActivity implements public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { if (grantResults.length > 0) if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { - if (requestCode == REQUEST_WRITE_LOGS) { - startExport(); + if (requestCode == REQUEST_CREATE_BACKUP) { + createBackup(); } } else { Toast.makeText(this, R.string.no_storage_permission, Toast.LENGTH_SHORT).show(); } } - private void startExport() { - ContextCompat.startForegroundService(this, new Intent(this, ExportLogsService.class)); + private void createBackup() { + ContextCompat.startForegroundService(this, new Intent(this, ExportBackupService.class)); } private void displayToast(final String msg) { diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java index 8ee20c688..7c2342ce0 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/AccountAdapter.java @@ -1,18 +1,17 @@ package eu.siacs.conversations.ui.adapter; -import android.content.Context; import android.content.res.Resources; +import android.databinding.DataBindingUtil; import android.graphics.Bitmap; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.os.AsyncTask; -import android.support.v7.widget.SwitchCompat; +import android.support.annotation.NonNull; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; import android.widget.ImageView; -import android.widget.TextView; import java.lang.ref.WeakReference; import java.util.List; @@ -20,6 +19,7 @@ import java.util.concurrent.RejectedExecutionException; import eu.siacs.conversations.Config; import eu.siacs.conversations.R; +import eu.siacs.conversations.databinding.AccountRowBinding; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.ui.XmppActivity; import eu.siacs.conversations.ui.util.StyledAttributes; @@ -27,155 +27,163 @@ import eu.siacs.conversations.utils.UIHelper; public class AccountAdapter extends ArrayAdapter { - private XmppActivity activity; - private boolean showStateButton; + private XmppActivity activity; + private boolean showStateButton; - public AccountAdapter(XmppActivity activity, List objects, boolean showStateButton) { - super(activity, 0, objects); - this.activity = activity; - this.showStateButton = showStateButton; - } + public AccountAdapter(XmppActivity activity, List objects, boolean showStateButton) { + super(activity, 0, objects); + this.activity = activity; + this.showStateButton = showStateButton; + } - public AccountAdapter(XmppActivity activity, List objects) { - super(activity, 0, objects); - this.activity = activity; - this.showStateButton = true; - } + public AccountAdapter(XmppActivity activity, List objects) { + super(activity, 0, objects); + this.activity = activity; + this.showStateButton = true; + } - @Override - public View getView(int position, View view, ViewGroup parent) { - final Account account = getItem(position); - if (view == null) { - LayoutInflater inflater = (LayoutInflater) getContext() - .getSystemService(Context.LAYOUT_INFLATER_SERVICE); - view = inflater.inflate(R.layout.account_row, parent, false); - } - TextView jid = view.findViewById(R.id.account_jid); - if (Config.DOMAIN_LOCK != null) { - jid.setText(account.getJid().getLocal()); - } else { - jid.setText(account.getJid().asBareJid().toString()); - } - TextView statusView = view.findViewById(R.id.account_status); - ImageView imageView = view.findViewById(R.id.account_image); - loadAvatar(account, imageView); - statusView.setText(getContext().getString(account.getStatus().getReadableId())); - switch (account.getStatus()) { - case ONLINE: - statusView.setTextColor(StyledAttributes.getColor(activity, R.attr.TextColorOnline)); - break; - case DISABLED: - case CONNECTING: - statusView.setTextColor(StyledAttributes.getColor(activity, android.R.attr.textColorSecondary)); - break; - default: - statusView.setTextColor(StyledAttributes.getColor(activity, R.attr.TextColorError)); - break; - } - final SwitchCompat tglAccountState = view.findViewById(R.id.tgl_account_status); - final boolean isDisabled = (account.getStatus() == Account.State.DISABLED); - tglAccountState.setOnCheckedChangeListener(null); - tglAccountState.setChecked(!isDisabled); - if (this.showStateButton) { - tglAccountState.setVisibility(View.VISIBLE); - } else { - tglAccountState.setVisibility(View.GONE); - } - tglAccountState.setOnCheckedChangeListener((compoundButton, b) -> { - if (b == isDisabled && activity instanceof OnTglAccountState) { - ((OnTglAccountState) activity).onClickTglAccountState(account, b); - } - }); - return view; - } + @Override + public View getView(int position, View view, @NonNull ViewGroup parent) { + final Account account = getItem(position); + final ViewHolder viewHolder; + if (view == null) { + AccountRowBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), R.layout.account_row, parent, false); + view = binding.getRoot(); + viewHolder = new ViewHolder(binding); + view.setTag(viewHolder); + } else { + viewHolder = (ViewHolder) view.getTag(); + } + if (Config.DOMAIN_LOCK != null) { + viewHolder.binding.accountJid.setText(account.getJid().getLocal()); + } else { + viewHolder.binding.accountJid.setText(account.getJid().asBareJid().toString()); + } + loadAvatar(account, viewHolder.binding.accountImage); + viewHolder.binding.accountStatus.setText(getContext().getString(account.getStatus().getReadableId())); + switch (account.getStatus()) { + case ONLINE: + viewHolder.binding.accountStatus.setTextColor(StyledAttributes.getColor(activity, R.attr.TextColorOnline)); + break; + case DISABLED: + case CONNECTING: + viewHolder.binding.accountStatus.setTextColor(StyledAttributes.getColor(activity, android.R.attr.textColorSecondary)); + break; + default: + viewHolder.binding.accountStatus.setTextColor(StyledAttributes.getColor(activity, R.attr.TextColorError)); + break; + } + final boolean isDisabled = (account.getStatus() == Account.State.DISABLED); + viewHolder.binding.tglAccountStatus.setOnCheckedChangeListener(null); + viewHolder.binding.tglAccountStatus.setChecked(!isDisabled); + if (this.showStateButton) { + viewHolder.binding.tglAccountStatus.setVisibility(View.VISIBLE); + } else { + viewHolder.binding.tglAccountStatus.setVisibility(View.GONE); + } + viewHolder.binding.tglAccountStatus.setOnCheckedChangeListener((compoundButton, b) -> { + if (b == isDisabled && activity instanceof OnTglAccountState) { + ((OnTglAccountState) activity).onClickTglAccountState(account, b); + } + }); + return view; + } - class BitmapWorkerTask extends AsyncTask { - private final WeakReference imageViewReference; - private Account account = null; + private static class ViewHolder { + private final AccountRowBinding binding; - public BitmapWorkerTask(ImageView imageView) { - imageViewReference = new WeakReference<>(imageView); - } + private ViewHolder(AccountRowBinding binding) { + this.binding = binding; + } + } - @Override - protected Bitmap doInBackground(Account... params) { - this.account = params[0]; - return activity.avatarService().get(this.account, activity.getPixel(48), isCancelled()); - } + class BitmapWorkerTask extends AsyncTask { + private final WeakReference imageViewReference; + private Account account = null; - @Override - protected void onPostExecute(Bitmap bitmap) { - if (bitmap != null && !isCancelled()) { - final ImageView imageView = imageViewReference.get(); - if (imageView != null) { - imageView.setImageBitmap(bitmap); - imageView.setBackgroundColor(0x00000000); - } - } - } - } + public BitmapWorkerTask(ImageView imageView) { + imageViewReference = new WeakReference<>(imageView); + } - public void loadAvatar(Account account, ImageView imageView) { - if (cancelPotentialWork(account, imageView)) { - final Bitmap bm = activity.avatarService().get(account, activity.getPixel(48), true); - if (bm != null) { - cancelPotentialWork(account, imageView); - imageView.setImageBitmap(bm); - imageView.setBackgroundColor(0x00000000); - } else { - imageView.setBackgroundColor(UIHelper.getColorForName(account.getJid().asBareJid().toString())); - imageView.setImageDrawable(null); - final BitmapWorkerTask task = new BitmapWorkerTask(imageView); - final AsyncDrawable asyncDrawable = new AsyncDrawable(activity.getResources(), null, task); - imageView.setImageDrawable(asyncDrawable); - try { - task.execute(account); - } catch (final RejectedExecutionException ignored) { - } - } - } - } + @Override + protected Bitmap doInBackground(Account... params) { + this.account = params[0]; + return activity.avatarService().get(this.account, activity.getPixel(48), isCancelled()); + } + + @Override + protected void onPostExecute(Bitmap bitmap) { + if (bitmap != null && !isCancelled()) { + final ImageView imageView = imageViewReference.get(); + if (imageView != null) { + imageView.setImageBitmap(bitmap); + imageView.setBackgroundColor(0x00000000); + } + } + } + } + + public void loadAvatar(Account account, ImageView imageView) { + if (cancelPotentialWork(account, imageView)) { + final Bitmap bm = activity.avatarService().get(account, activity.getPixel(48), true); + if (bm != null) { + cancelPotentialWork(account, imageView); + imageView.setImageBitmap(bm); + imageView.setBackgroundColor(0x00000000); + } else { + imageView.setBackgroundColor(UIHelper.getColorForName(account.getJid().asBareJid().toString())); + imageView.setImageDrawable(null); + final BitmapWorkerTask task = new BitmapWorkerTask(imageView); + final AsyncDrawable asyncDrawable = new AsyncDrawable(activity.getResources(), null, task); + imageView.setImageDrawable(asyncDrawable); + try { + task.execute(account); + } catch (final RejectedExecutionException ignored) { + } + } + } + } - public interface OnTglAccountState { - void onClickTglAccountState(Account account, boolean state); - } + public interface OnTglAccountState { + void onClickTglAccountState(Account account, boolean state); + } - public static boolean cancelPotentialWork(Account account, ImageView imageView) { - final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); + public static boolean cancelPotentialWork(Account account, ImageView imageView) { + final BitmapWorkerTask bitmapWorkerTask = getBitmapWorkerTask(imageView); - if (bitmapWorkerTask != null) { - final Account oldAccount = bitmapWorkerTask.account; - if (oldAccount == null || account != oldAccount) { - bitmapWorkerTask.cancel(true); - } else { - return false; - } - } - return true; - } + if (bitmapWorkerTask != null) { + final Account oldAccount = bitmapWorkerTask.account; + if (oldAccount == null || account != oldAccount) { + bitmapWorkerTask.cancel(true); + } else { + return false; + } + } + return true; + } - private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) { - if (imageView != null) { - final Drawable drawable = imageView.getDrawable(); - if (drawable instanceof AsyncDrawable) { - final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable; - return asyncDrawable.getBitmapWorkerTask(); - } - } - return null; - } + private static BitmapWorkerTask getBitmapWorkerTask(ImageView imageView) { + if (imageView != null) { + final Drawable drawable = imageView.getDrawable(); + if (drawable instanceof AsyncDrawable) { + final AsyncDrawable asyncDrawable = (AsyncDrawable) drawable; + return asyncDrawable.getBitmapWorkerTask(); + } + } + return null; + } - static class AsyncDrawable extends BitmapDrawable { - private final WeakReference bitmapWorkerTaskReference; + static class AsyncDrawable extends BitmapDrawable { + private final WeakReference bitmapWorkerTaskReference; - public AsyncDrawable(Resources res, Bitmap bitmap, BitmapWorkerTask bitmapWorkerTask) { - super(res, bitmap); - bitmapWorkerTaskReference = new WeakReference<>(bitmapWorkerTask); - } + public AsyncDrawable(Resources res, Bitmap bitmap, BitmapWorkerTask bitmapWorkerTask) { + super(res, bitmap); + bitmapWorkerTaskReference = new WeakReference<>(bitmapWorkerTask); + } - public BitmapWorkerTask getBitmapWorkerTask() { - return bitmapWorkerTaskReference.get(); - } - } + public BitmapWorkerTask getBitmapWorkerTask() { + return bitmapWorkerTaskReference.get(); + } + } } diff --git a/src/main/java/eu/siacs/conversations/utils/BackupFileHeader.java b/src/main/java/eu/siacs/conversations/utils/BackupFileHeader.java new file mode 100644 index 000000000..bc86a5d24 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/utils/BackupFileHeader.java @@ -0,0 +1,85 @@ +package eu.siacs.conversations.utils; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.Arrays; + +import rocks.xmpp.addr.Jid; + +public class BackupFileHeader { + + private static final int VERSION = 1; + + private String app; + private Jid jid; + private long timestamp; + private byte[] iv; + private byte[] salt; + + + @Override + public String toString() { + return "BackupFileHeader{" + + "app='" + app + '\'' + + ", jid=" + jid + + ", timestamp=" + timestamp + + ", iv=" + CryptoHelper.bytesToHex(iv) + + ", salt=" + CryptoHelper.bytesToHex(salt) + + '}'; + } + + public BackupFileHeader(String app, Jid jid, long timestamp, byte[] iv, byte[] salt) { + this.app = app; + this.jid = jid; + this.timestamp = timestamp; + this.iv = iv; + this.salt = salt; + } + + public void write(DataOutputStream dataOutputStream) throws IOException { + dataOutputStream.writeInt(VERSION); + dataOutputStream.writeUTF(app); + dataOutputStream.writeUTF(jid.asBareJid().toEscapedString()); + dataOutputStream.writeLong(timestamp); + dataOutputStream.write(iv); + dataOutputStream.write(salt); + } + + public static BackupFileHeader read(DataInputStream inputStream) throws IOException { + final int version = inputStream.readInt(); + if (version > VERSION) { + throw new IllegalArgumentException("Backup File version was "+version+" but app only supports up to version "+VERSION); + } + String app = inputStream.readUTF(); + String jid = inputStream.readUTF(); + long timestamp = inputStream.readLong(); + byte[] iv = new byte[12]; + inputStream.readFully(iv); + byte[] salt = new byte[16]; + inputStream.readFully(salt); + + return new BackupFileHeader(app,Jid.of(jid),timestamp,iv,salt); + + } + + public byte[] getSalt() { + return salt; + } + + public byte[] getIv() { + return iv; + } + + public Jid getJid() { + return jid; + } + + public String getApp() { + return app; + } + + public long getTimestamp() { + return timestamp; + } +} diff --git a/src/main/res/drawable-hdpi/ic_archive_white_24dp.png b/src/main/res/drawable-hdpi/ic_archive_white_24dp.png new file mode 100644 index 000000000..bb72e890f Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_archive_white_24dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_import_export_white_24dp.png b/src/main/res/drawable-hdpi/ic_import_export_white_24dp.png deleted file mode 100644 index 705a4cc70..000000000 Binary files a/src/main/res/drawable-hdpi/ic_import_export_white_24dp.png and /dev/null differ diff --git a/src/main/res/drawable-mdpi/ic_archive_white_24dp.png b/src/main/res/drawable-mdpi/ic_archive_white_24dp.png new file mode 100644 index 000000000..f6aa3f966 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_archive_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_import_export_white_24dp.png b/src/main/res/drawable-mdpi/ic_import_export_white_24dp.png deleted file mode 100644 index 5f6e11bc8..000000000 Binary files a/src/main/res/drawable-mdpi/ic_import_export_white_24dp.png and /dev/null differ diff --git a/src/main/res/drawable-xhdpi/ic_archive_white_24dp.png b/src/main/res/drawable-xhdpi/ic_archive_white_24dp.png new file mode 100644 index 000000000..3513bd9fe Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_archive_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_import_export_white_24dp.png b/src/main/res/drawable-xhdpi/ic_import_export_white_24dp.png deleted file mode 100644 index fb82f4208..000000000 Binary files a/src/main/res/drawable-xhdpi/ic_import_export_white_24dp.png and /dev/null differ diff --git a/src/main/res/drawable-xxhdpi/ic_archive_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_archive_white_24dp.png new file mode 100644 index 000000000..00e04e42b Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_archive_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_import_export_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_import_export_white_24dp.png deleted file mode 100644 index c34970372..000000000 Binary files a/src/main/res/drawable-xxhdpi/ic_import_export_white_24dp.png and /dev/null differ diff --git a/src/main/res/drawable-xxxhdpi/ic_archive_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_archive_white_24dp.png new file mode 100644 index 000000000..34cd3fd80 Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_archive_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_import_export_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_import_export_white_24dp.png deleted file mode 100644 index 06b27ea19..000000000 Binary files a/src/main/res/drawable-xxxhdpi/ic_import_export_white_24dp.png and /dev/null differ diff --git a/src/main/res/layout/account_row.xml b/src/main/res/layout/account_row.xml index 22482b0d3..4cd44b0aa 100644 --- a/src/main/res/layout/account_row.xml +++ b/src/main/res/layout/account_row.xml @@ -1,55 +1,57 @@ - + + + + + + + + + android:scrollHorizontally="false" + android:singleLine="true" + android:textAppearance="@style/TextAppearance.Conversations.Subhead" /> - + + - - - + android:layout_alignParentRight="true" + android:layout_centerVertical="true" + android:padding="16dp" + android:focusable="false" /> - - - - - - \ No newline at end of file + + \ No newline at end of file diff --git a/src/main/res/layout/dialog_quickedit.xml b/src/main/res/layout/dialog_quickedit.xml index 00189eec3..f0b71fc1b 100644 --- a/src/main/res/layout/dialog_quickedit.xml +++ b/src/main/res/layout/dialog_quickedit.xml @@ -6,9 +6,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" - android:paddingLeft="?attr/dialog_horizontal_padding" - android:paddingRight="?attr/dialog_horizontal_padding" - android:paddingTop="?attr/dialog_vertical_padding"> + android:padding="?dialogPreferredPadding"> Try again Keep service in foreground Prevents the operating system from killing your connection - Export history - Write conversations history logs to SD card - Writing logs to SD card + Create backup + Write backup files to %s + Creating backup files + Restoring backup + Your backup has been restored + Do not forget to enable the account. Choose file Receiving %1$s (%2$d%% completed) Download %s @@ -747,7 +750,6 @@ Video compression View media Media browser - History export File omitted due to security violation. Video Quality Lower quality means smaller files @@ -811,4 +813,11 @@ Open with… Conversations profile picture Choose account + Restore backup + Restore + Enter your password for the account %s to restore the backup. + Do not use the restore backup feature in an attempt to clone (run simultaneously) an installation. Restoring a backup is only meant for migrations or in case you’ve lost the original device. + Unable to restore backup + Unable to decrypt backup + diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml index 70841d057..971fb8ef9 100644 --- a/src/main/res/values/themes.xml +++ b/src/main/res/values/themes.xml @@ -95,7 +95,6 @@ @drawable/ic_search_white_24dp @drawable/ic_lock_open_white_24dp @drawable/ic_settings_black_24dp - @drawable/ic_import_export_white_24dp @drawable/ic_share_white_24dp @drawable/ic_qr_code_scan_white_24dp @drawable/ic_scroll_to_end_black @@ -208,7 +207,6 @@ @drawable/ic_search_white_24dp @drawable/ic_lock_open_white_24dp @drawable/ic_settings_white_24dp - @drawable/ic_import_export_white_24dp @drawable/ic_share_white_24dp @drawable/ic_qr_code_scan_white_24dp @drawable/ic_scroll_to_end_white diff --git a/src/main/res/xml/preferences.xml b/src/main/res/xml/preferences.xml index ab5d21263..4b93528f4 100644 --- a/src/main/res/xml/preferences.xml +++ b/src/main/res/xml/preferences.xml @@ -330,9 +330,9 @@ android:summary="@string/pref_keep_foreground_service_summary" android:title="@string/pref_keep_foreground_service" /> + android:key="create_backup" + android:summary="@string/pref_create_backup_summary" + android:title="@string/pref_create_backup" />