diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..8ade90d85 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +liberapay: inputmice +custom: https://paypal.me/ConversationsIM diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index d26e15c2c..b5f52feda 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,12 +1,12 @@ #### General information -* **Version:** eg 1.21.0 -* **Device:** eg Google Nexus 5 -* **Android Version:** eg Android 6.0 Stock or Android 5.1 Cyanogenmod -* **Server name:** eg conversations.im, jabber.at or self hosted +* **Version:** 2.5.3 +* **Device:** Xiaomi Mi A1 +* **Android Version:** Android 9 (stock) +* **Server name:** conversations.im, jabber.at or self hosted * **Server software:** ejabberd 16.04 or prosody 0.10 (if known) -* **Installed server modules:** eg Stream Managment, CSI, MAM -* **Conversations source:** eg PlayStore, PlayStore Beta Channel, F-Droid, self build (latest HEAD) +* **Installed server modules:** Stream Managment, CSI, MAM +* **Conversations source:** PlayStore, PlayStore Beta Channel, F-Droid, self build (latest HEAD) #### Steps to reproduce diff --git a/CHANGELOG.md b/CHANGELOG.md index 42fe078ee..c57da8926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### Version 2.5.5 +* allow backups to be restored from anywhere +* bug fixes + ### Version 2.5.4 * stability improvements for group chats and channels diff --git a/build.gradle b/build.gradle index 3abe985b5..c976b3b13 100644 --- a/build.gradle +++ b/build.gradle @@ -81,8 +81,8 @@ android { defaultConfig { minSdkVersion 16 targetSdkVersion 25 - versionCode 333 - versionName "2.5.4" + versionCode 334 + versionName "2.5.5" archivesBaseName += "-$versionName" applicationId "eu.sum7.conversations" resValue "string", "applicationId", applicationId diff --git a/src/conversations/AndroidManifest.xml b/src/conversations/AndroidManifest.xml index 0a01c6c0f..a91f4c129 100644 --- a/src/conversations/AndroidManifest.xml +++ b/src/conversations/AndroidManifest.xml @@ -23,7 +23,20 @@ + 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 index 9892e6492..d8246a6af 100644 --- a/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java +++ b/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java @@ -7,6 +7,7 @@ import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; import android.os.Binder; import android.os.IBinder; import android.support.v4.app.NotificationCompat; @@ -16,18 +17,20 @@ import java.io.BufferedReader; import java.io.DataInputStream; import java.io.File; import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.WeakHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.zip.GZIPInputStream; +import java.util.zip.ZipException; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; @@ -81,14 +84,22 @@ public class ImportBackupService extends Service { return START_NOT_STICKY; } final String password = intent.getStringExtra("password"); - final String file = intent.getStringExtra("file"); - if (password == null || file == null) { + final Uri data = intent.getData(); + final Uri uri; + if (data == null) { + final String file = intent.getStringExtra("file"); + uri = file == null ? null : Uri.fromFile(new File(file)); + } else { + uri = data; + } + + if (password == null || password.isEmpty() || uri == null) { return START_NOT_STICKY; } if (running.compareAndSet(false, true)) { executor.execute(() -> { startForegroundService(); - final boolean success = importBackup(new File(file), password); + final boolean success = importBackup(uri, password); stopForeground(true); running.set(false); if (success) { @@ -122,7 +133,7 @@ public class ImportBackupService extends Service { try { final BackupFile backupFile = BackupFile.read(file); if (accounts.contains(backupFile.getHeader().getJid())) { - Log.d(Config.LOGTAG,"skipping backup for "+backupFile.getHeader().getJid()); + Log.d(Config.LOGTAG, "skipping backup for " + backupFile.getHeader().getJid()); } else { backupFiles.add(backupFile); } @@ -145,21 +156,35 @@ public class ImportBackupService extends Service { startForeground(NOTIFICATION_ID, mBuilder.build()); } - private boolean importBackup(File file, String password) { - Log.d(Config.LOGTAG, "importing backup from file " + file.getAbsolutePath()); + private boolean importBackup(Uri uri, String password) { + Log.d(Config.LOGTAG, "importing backup from " + uri); try { SQLiteDatabase db = mDatabaseBackend.getWritableDatabase(); - final FileInputStream fileInputStream = new FileInputStream(file); - final DataInputStream dataInputStream = new DataInputStream(fileInputStream); - BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream); + final InputStream inputStream; + if ("file".equals(uri.getScheme())) { + inputStream = new FileInputStream(new File(uri.getPath())); + } else { + inputStream = getContentResolver().openInputStream(uri); + } + final DataInputStream dataInputStream = new DataInputStream(inputStream); + final BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream); Log.d(Config.LOGTAG, backupFileHeader.toString()); + if (mDatabaseBackend.getAccountJids(false).contains(backupFileHeader.getJid())) { + synchronized (mOnBackupProcessedListeners) { + for (OnBackupProcessed l : mOnBackupProcessedListeners) { + l.onAccountAlreadySetup(); + } + } + return false; + } + 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); + CipherInputStream cipherInputStream = new CipherInputStream(inputStream, cipher); GZIPInputStream gzipInputStream = new GZIPInputStream(cipherInputStream); BufferedReader reader = new BufferedReader(new InputStreamReader(gzipInputStream, "UTF-8")); @@ -197,12 +222,7 @@ public class ImportBackupService extends Service { return true; } catch (Exception e) { Throwable throwable = e.getCause(); - final boolean reasonWasCrypto; - if (throwable instanceof BadPaddingException) { - reasonWasCrypto = true; - } else { - reasonWasCrypto = false; - } + final boolean reasonWasCrypto = throwable instanceof BadPaddingException || e instanceof ZipException; synchronized (mOnBackupProcessedListeners) { for (OnBackupProcessed l : mOnBackupProcessedListeners) { if (reasonWasCrypto) { @@ -212,7 +232,7 @@ public class ImportBackupService extends Service { } } } - Log.d(Config.LOGTAG, "error restoring backup " + file.getAbsolutePath(), e); + Log.d(Config.LOGTAG, "error restoring backup " + uri, e); return false; } } @@ -259,14 +279,16 @@ public class ImportBackupService extends Service { void onBackupDecryptionFailed(); void onBackupRestoreFailed(); + + void onAccountAlreadySetup(); } public static class BackupFile { - private final File file; + private final Uri uri; private final BackupFileHeader header; - private BackupFile(File file, BackupFileHeader header) { - this.file = file; + private BackupFile(Uri uri, BackupFileHeader header) { + this.uri = uri; this.header = header; } @@ -275,15 +297,26 @@ public class ImportBackupService extends Service { final DataInputStream dataInputStream = new DataInputStream(fileInputStream); BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream); fileInputStream.close(); - return new BackupFile(file, backupFileHeader); + return new BackupFile(Uri.fromFile(file), backupFileHeader); + } + + public static BackupFile read(final Context context, final Uri uri) throws IOException { + final InputStream inputStream = context.getContentResolver().openInputStream(uri); + if (inputStream == null) { + throw new FileNotFoundException(); + } + final DataInputStream dataInputStream = new DataInputStream(inputStream); + BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream); + inputStream.close(); + return new BackupFile(uri, backupFileHeader); } public BackupFileHeader getHeader() { return header; } - public File getFile() { - return file; + public Uri getUri() { + return uri; } } diff --git a/src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java b/src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java index d5de333da..d6dbfd222 100644 --- a/src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java +++ b/src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java @@ -2,9 +2,12 @@ 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.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.support.design.widget.Snackbar; @@ -13,8 +16,11 @@ import android.support.v7.app.AlertDialog; import android.support.v7.widget.Toolbar; import android.util.Log; import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; import android.view.View; +import java.io.IOException; import java.util.List; import eu.siacs.conversations.Config; @@ -32,6 +38,8 @@ public class ImportBackupActivity extends ActionBarActivity implements ServiceCo private BackupFileAdapter backupFileAdapter; private ImportBackupService service; + private boolean mLoadingState = false; + private int mTheme; @Override @@ -41,12 +49,26 @@ public class ImportBackupActivity extends ActionBarActivity implements ServiceCo super.onCreate(savedInstanceState); binding = DataBindingUtil.setContentView(this, R.layout.activity_import_backup); setSupportActionBar((Toolbar) binding.toolbar); - configureActionBar(getSupportActionBar()); + setLoadingState(savedInstanceState != null && savedInstanceState.getBoolean("loading_state", false)); this.backupFileAdapter = new BackupFileAdapter(); this.binding.list.setAdapter(this.backupFileAdapter); this.backupFileAdapter.setOnItemClickedListener(this); } + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + getMenuInflater().inflate(R.menu.import_backup, menu); + final MenuItem openBackup = menu.findItem(R.id.action_open_backup_file); + openBackup.setVisible(!this.mLoadingState); + return true; + } + + @Override + public void onSaveInstanceState(Bundle bundle) { + bundle.putBoolean("loading_state", this.mLoadingState); + super.onSaveInstanceState(bundle); + } + @Override public void onStart() { super.onStart(); @@ -56,6 +78,13 @@ public class ImportBackupActivity extends ActionBarActivity implements ServiceCo } else { bindService(new Intent(this, ImportBackupService.class), this, Context.BIND_AUTO_CREATE); } + final Intent intent = getIntent(); + if (intent != null && Intent.ACTION_VIEW.equals(intent.getAction()) && !this.mLoadingState) { + Uri uri = intent.getData(); + if (uri != null) { + openBackupFileFromUri(uri, true); + } + } } @Override @@ -87,31 +116,83 @@ public class ImportBackupActivity extends ActionBarActivity implements ServiceCo } @Override - public void onClick(ImportBackupService.BackupFile backupFile) { + public void onClick(final ImportBackupService.BackupFile backupFile) { + showEnterPasswordDialog(backupFile, false); + } + + private void openBackupFileFromUri(final Uri uri, final boolean finishOnCancel) { + try { + final ImportBackupService.BackupFile backupFile = ImportBackupService.BackupFile.read(this, uri); + showEnterPasswordDialog(backupFile, finishOnCancel); + } catch (IOException e) { + Snackbar.make(binding.coordinator, R.string.not_a_backup_file, Snackbar.LENGTH_LONG).show(); + } + } + + private void showEnterPasswordDialog(final ImportBackupService.BackupFile backupFile, final boolean finishOnCancel) { 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()); + Log.d(Config.LOGTAG, "attempting to import " + backupFile.getUri()); 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()); - setLoadingState(true); - ContextCompat.startForegroundService(this, intent); + builder.setNegativeButton(R.string.cancel, (dialog, which) -> { + if (finishOnCancel) { + finish(); + } }); + builder.setPositiveButton(R.string.restore, null); builder.setCancelable(false); - builder.create().show(); + final AlertDialog dialog = builder.create(); + dialog.setOnShowListener((d) -> { + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(v -> { + final String password = enterPasswordBinding.accountPassword.getEditableText().toString(); + if (password.isEmpty()) { + enterPasswordBinding.accountPasswordLayout.setError(getString(R.string.please_enter_password)); + return; + } + final Uri uri = backupFile.getUri(); + Intent intent = new Intent(this, ImportBackupService.class); + intent.setAction(Intent.ACTION_SEND); + intent.putExtra("password", password); + if ("file".equals(uri.getScheme())) { + intent.putExtra("file", uri.getPath()); + } else { + intent.setData(uri); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + setLoadingState(true); + ContextCompat.startForegroundService(this, intent); + d.dismiss(); + }); + }); + dialog.show(); } private void setLoadingState(final boolean loadingState) { - binding.coordinator.setVisibility(loadingState ? View.GONE :View.VISIBLE); + binding.coordinator.setVisibility(loadingState ? View.GONE : View.VISIBLE); binding.inProgress.setVisibility(loadingState ? View.VISIBLE : View.GONE); setTitle(loadingState ? R.string.restoring_backup : R.string.restore_backup); - configureActionBar(getSupportActionBar(),!loadingState); + configureActionBar(getSupportActionBar(), !loadingState); + this.mLoadingState = loadingState; + invalidateOptionsMenu(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent intent) { + if (resultCode == RESULT_OK) { + if (requestCode == 0xbac) { + openBackupFileFromUri(intent.getData(), false); + } + } + } + + @Override + public void onAccountAlreadySetup() { + runOnUiThread(() -> { + setLoadingState(false); + Snackbar.make(binding.coordinator, R.string.account_already_setup, Snackbar.LENGTH_LONG).show(); + }); } @Override @@ -126,17 +207,32 @@ public class ImportBackupActivity extends ActionBarActivity implements ServiceCo @Override public void onBackupDecryptionFailed() { - runOnUiThread(()-> { + runOnUiThread(() -> { setLoadingState(false); - Snackbar.make(binding.coordinator,R.string.unable_to_decrypt_backup,Snackbar.LENGTH_LONG).show(); + Snackbar.make(binding.coordinator, R.string.unable_to_decrypt_backup, Snackbar.LENGTH_LONG).show(); }); } @Override public void onBackupRestoreFailed() { - runOnUiThread(()-> { + runOnUiThread(() -> { setLoadingState(false); - Snackbar.make(binding.coordinator,R.string.unable_to_restore_backup,Snackbar.LENGTH_LONG).show(); + Snackbar.make(binding.coordinator, R.string.unable_to_restore_backup, Snackbar.LENGTH_LONG).show(); }); } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.action_open_backup_file) { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType("*/*"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false); + } + intent.addCategory(Intent.CATEGORY_OPENABLE); + startActivityForResult(Intent.createChooser(intent, getString(R.string.open_backup)), 0xbac); + return true; + } + return super.onOptionsItemSelected(item); + } } diff --git a/src/conversations/java/eu/siacs/conversations/utils/SignupUtils.java b/src/conversations/java/eu/siacs/conversations/utils/SignupUtils.java index 6e8ed2eff..fc5d874d3 100644 --- a/src/conversations/java/eu/siacs/conversations/utils/SignupUtils.java +++ b/src/conversations/java/eu/siacs/conversations/utils/SignupUtils.java @@ -20,13 +20,12 @@ public class SignupUtils { } public static Intent getSignUpIntent(final Activity activity, final boolean toServerChooser) { - Intent intent; + final Intent intent; if (toServerChooser) { intent = new Intent(activity, PickServerActivity.class); } else { intent = new Intent(activity, WelcomeActivity.class); } - StartConversationActivity.addInviteUri(intent, activity.getIntent()); return intent; } diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 6b43106f9..31f33e5fd 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -92,7 +92,6 @@ android:theme="@style/SplashTheme"> - diff --git a/src/main/java/eu/siacs/conversations/crypto/XmppDomainVerifier.java b/src/main/java/eu/siacs/conversations/crypto/XmppDomainVerifier.java index f4ca658d4..1b618c5ad 100644 --- a/src/main/java/eu/siacs/conversations/crypto/XmppDomainVerifier.java +++ b/src/main/java/eu/siacs/conversations/crypto/XmppDomainVerifier.java @@ -22,6 +22,7 @@ import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Locale; import javax.net.ssl.SSLSession; @@ -80,14 +81,14 @@ public class XmppDomainVerifier implements DomainHostnameVerifier { break; } Log.d(LOGTAG, "comparing " + needle.substring(i) + " and " + entry.substring(1)); - if (needle.substring(i).equals(entry.substring(1))) { + if (needle.substring(i).equalsIgnoreCase(entry.substring(1))) { Log.d(LOGTAG, "domain " + needle + " matched " + entry); return true; } offset = i + 1; } } else { - if (entry.equals(needle)) { + if (entry.equalsIgnoreCase(needle)) { Log.d(LOGTAG, "domain " + needle + " matched " + entry); return true; } @@ -117,25 +118,25 @@ public class XmppDomainVerifier implements DomainHostnameVerifier { List domains = new ArrayList<>(); if (alternativeNames != null) { for (List san : alternativeNames) { - Integer type = (Integer) san.get(0); + final Integer type = (Integer) san.get(0); if (type == 0) { - Pair otherName = parseOtherName((byte[]) san.get(1)); - if (otherName != null) { + final Pair otherName = parseOtherName((byte[]) san.get(1)); + if (otherName != null && otherName.first != null && otherName.second != null) { switch (otherName.first) { case SRV_NAME: - srvNames.add(otherName.second); + srvNames.add(otherName.second.toLowerCase(Locale.US)); break; case XMPP_ADDR: - xmppAddrs.add(otherName.second); + xmppAddrs.add(otherName.second.toLowerCase(Locale.US)); break; default: Log.d(LOGTAG, "oid: " + otherName.first + " value: " + otherName.second); } } } else if (type == 2) { - Object value = san.get(1); + final Object value = san.get(1); if (value instanceof String) { - domains.add((String) value); + domains.add(((String) value).toLowerCase(Locale.US)); } } } diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index c9ec5637a..2004953ee 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/src/main/java/eu/siacs/conversations/entities/Conversation.java @@ -285,7 +285,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl final ArrayList results = new ArrayList<>(); synchronized (this.messages) { for (Message message : this.messages) { - if (message.getType() != Message.TYPE_IMAGE && message.getStatus() == Message.STATUS_UNSEND) { + if ((message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost()) && message.getStatus() == Message.STATUS_UNSEND) { results.add(message); } } diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index 5adaffe34..33179b851 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -543,7 +543,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece final boolean fingerprintsMatch = replacedMessage.getFingerprint() == null || replacedMessage.getFingerprint().equals(message.getFingerprint()); final boolean trueCountersMatch = replacedMessage.getTrueCounterpart() != null - && replacedMessage.getTrueCounterpart().equals(message.getTrueCounterpart()); + && message.getTrueCounterpart() != null + && replacedMessage.getTrueCounterpart().asBareJid().equals(message.getTrueCounterpart().asBareJid()); final boolean mucUserMatches = query == null && replacedMessage.sameMucUser(message); //can not be checked when using mam final boolean duplicate = conversation.hasDuplicateMessage(message); if (fingerprintsMatch && (trueCountersMatch || !conversationMultiMode || mucUserMatches) && !duplicate) { diff --git a/src/main/java/eu/siacs/conversations/services/ExportBackupService.java b/src/main/java/eu/siacs/conversations/services/ExportBackupService.java index d715ea539..7b034a255 100644 --- a/src/main/java/eu/siacs/conversations/services/ExportBackupService.java +++ b/src/main/java/eu/siacs/conversations/services/ExportBackupService.java @@ -21,7 +21,9 @@ import java.io.PrintWriter; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.spec.InvalidKeySpecException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.zip.GZIPOutputStream; @@ -50,6 +52,8 @@ public class ExportBackupService extends Service { public static final String CIPHERMODE = "AES/GCM/NoPadding"; public static final String PROVIDER = "BC"; + public static final String MIME_TYPE = "application/vnd.conversations.backup"; + private static final int NOTIFICATION_ID = 19; private static final int PAGE_SIZE = 20; private static AtomicBoolean running = new AtomicBoolean(false); @@ -213,11 +217,19 @@ public class ExportBackupService extends Service { public int onStartCommand(Intent intent, int flags, int startId) { if (running.compareAndSet(false, true)) { new Thread(() -> { - final boolean success = export(); + boolean success; + List files; + try { + files = export(); + success = true; + } catch (Exception e) { + success = false; + files = Collections.emptyList(); + } stopForeground(true); running.set(false); if (success) { - notifySuccess(); + notifySuccess(files); } stopSelf(); }).start(); @@ -250,81 +262,97 @@ public class ExportBackupService extends Service { } } - private boolean export() { + private List export() throws Exception { 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++; + int count = 0; + final int max = this.mAccounts.size(); + final SecureRandom secureRandom = new SecureRandom(); + final List files = new ArrayList<>(); + 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"); + files.add(file); + if (file.getParentFile().mkdirs()) { + Log.d(Config.LOGTAG, "created backup directory " + file.getParentFile().getAbsolutePath()); } - return true; - } catch (Exception e) { - Log.d(Config.LOGTAG, "unable to create backup ", e); - return false; + 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++; } + return files; } - private void notifySuccess() { + private void notifySuccess(List files) { final String path = FileBackend.getBackupDirectory(this); - PendingIntent pendingIntent = null; + PendingIntent openFolderIntent = null; for (Intent intent : getPossibleFileOpenIntents(this, path)) { if (intent.resolveActivityInfo(getPackageManager(), 0) != null) { - pendingIntent = PendingIntent.getActivity(this, 189, intent, PendingIntent.FLAG_UPDATE_CURRENT); + openFolderIntent = PendingIntent.getActivity(this, 189, intent, PendingIntent.FLAG_UPDATE_CURRENT); break; } } + PendingIntent shareFilesIntent = null; + if (files.size() > 0) { + final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE); + ArrayList uris = new ArrayList<>(); + for(File file : files) { + uris.add(FileBackend.getUriForFile(this, file)); + } + intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + intent.setType(MIME_TYPE); + final Intent chooser = Intent.createChooser(intent, getString(R.string.share_backup_files)); + shareFilesIntent = PendingIntent.getActivity(this,190, chooser, PendingIntent.FLAG_UPDATE_CURRENT); + } + NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup"); mBuilder.setContentTitle(getString(R.string.notification_backup_created_title)) .setContentText(getString(R.string.notification_backup_created_subtitle, path)) .setStyle(new NotificationCompat.BigTextStyle().bigText(getString(R.string.notification_backup_created_subtitle, FileBackend.getBackupDirectory(this)))) .setAutoCancel(true) - .setContentIntent(pendingIntent) + .setContentIntent(openFolderIntent) .setSmallIcon(R.drawable.ic_archive_white_24dp); + + if (shareFilesIntent != null) { + mBuilder.addAction(R.drawable.ic_share_white_24dp, getString(R.string.share_backup_files), shareFilesIntent); + } + notificationManager.notify(NOTIFICATION_ID, mBuilder.build()); } diff --git a/src/main/java/eu/siacs/conversations/ui/ShareWithActivity.java b/src/main/java/eu/siacs/conversations/ui/ShareWithActivity.java index 9d6385db8..ce64af856 100644 --- a/src/main/java/eu/siacs/conversations/ui/ShareWithActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/ShareWithActivity.java @@ -131,10 +131,11 @@ public class ShareWithActivity extends XmppActivity implements XmppConnectionSer if (Intent.ACTION_SEND.equals(action)) { final String text = intent.getStringExtra(Intent.EXTRA_TEXT); final Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM); + if (data != null && "geo".equals(data.getScheme())) { this.share.uris.clear(); this.share.uris.add(data); - } else if (type != null && uri != null && (text == null || !type.equals("text/plain"))) { + } else if (type != null && uri != null) { this.share.uris.clear(); this.share.uris.add(uri); } else { diff --git a/src/main/java/eu/siacs/conversations/ui/UriHandlerActivity.java b/src/main/java/eu/siacs/conversations/ui/UriHandlerActivity.java index 95f179ceb..d1c0fa7d4 100644 --- a/src/main/java/eu/siacs/conversations/ui/UriHandlerActivity.java +++ b/src/main/java/eu/siacs/conversations/ui/UriHandlerActivity.java @@ -90,6 +90,7 @@ public class UriHandlerActivity extends AppCompatActivity { if (accounts.size() == 0) { if (xmppUri.isJidValid()) { intent = SignupUtils.getSignUpIntent(this); + intent.putExtra(StartConversationActivity.EXTRA_INVITE_URI, xmppUri.toString()); startActivity(intent); } else { Toast.makeText(this, R.string.invalid_jid, Toast.LENGTH_SHORT).show(); diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java index ad5ddce89..3ea451653 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/ConversationAdapter.java @@ -94,6 +94,7 @@ public class ConversationAdapter extends RecyclerView.Adapter LOCALPART_BLACKLIST = Arrays.asList("xmpp","jabber","me"); + private static List LOCAL_PART_BLACKLIST = Arrays.asList("xmpp", "jabber", "me"); - public static String localPartOrFallback(Jid jid) { - if (LOCALPART_BLACKLIST.contains(jid.getLocal().toLowerCase(Locale.ENGLISH))) { - final String domain = jid.getDomain(); - final int index = domain.lastIndexOf('.'); - return index > 1 ? domain.substring(0,index) : domain; - } else { - return jid.getLocal(); - } - } + public static String localPartOrFallback(Jid jid) { + if (LOCAL_PART_BLACKLIST.contains(jid.getLocal().toLowerCase(Locale.ENGLISH))) { + final String domain = jid.getDomain(); + final int index = domain.indexOf('.'); + return index > 1 ? domain.substring(0, index) : domain; + } else { + return jid.getLocal(); + } + } - public static Jid parseOrFallbackToInvalid(String jid) { - try { - return Jid.of(jid); - } catch (IllegalArgumentException e) { - return InvalidJid.of(jid, true); - } - } + public static Jid parseOrFallbackToInvalid(String jid) { + try { + return Jid.of(jid); + } catch (IllegalArgumentException e) { + return InvalidJid.of(jid, true); + } + } } diff --git a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java b/src/main/java/eu/siacs/conversations/utils/MimeUtils.java index 59ad512c5..aaaef8e0d 100644 --- a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java +++ b/src/main/java/eu/siacs/conversations/utils/MimeUtils.java @@ -29,6 +29,7 @@ import java.util.Properties; import eu.siacs.conversations.Config; import eu.siacs.conversations.entities.Transferable; +import eu.siacs.conversations.services.ExportBackupService; /** * Utilities for dealing with MIME types. @@ -72,6 +73,7 @@ public final class MimeUtils { add("application/vnd.amazon.mobi8-ebook","kfx"); add("application/vnd.android.package-archive", "apk"); add("application/vnd.cinderella", "cdy"); + add(ExportBackupService.MIME_TYPE, "ceb"); add("application/vnd.ms-pki.stl", "stl"); add("application/vnd.oasis.opendocument.database", "odb"); add("application/vnd.oasis.opendocument.formula", "odf"); diff --git a/src/main/java/eu/siacs/conversations/utils/UIHelper.java b/src/main/java/eu/siacs/conversations/utils/UIHelper.java index e9360226f..84df63dc2 100644 --- a/src/main/java/eu/siacs/conversations/utils/UIHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/UIHelper.java @@ -32,6 +32,7 @@ import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.entities.MucOptions; import eu.siacs.conversations.entities.Presence; import eu.siacs.conversations.entities.Transferable; +import eu.siacs.conversations.services.ExportBackupService; import rocks.xmpp.addr.Jid; public class UIHelper { @@ -483,8 +484,12 @@ public class UIHelper { return context.getString(R.string.pdf_document); } else if (mime.equals("application/vnd.android.package-archive")) { return context.getString(R.string.apk); + } else if (mime.equals(ExportBackupService.MIME_TYPE)) { + return context.getString(R.string.conversations_backup); } else if (mime.contains("vcard")) { return context.getString(R.string.vcard); + } else if (mime.equals("text/x-vcalendar") || mime.equals("text/calendar")) { + return context.getString(R.string.event); } else if (mime.equals("application/epub+zip") || mime.equals("application/vnd.amazon.mobi8-ebook")) { return context.getString(R.string.ebook); } else { diff --git a/src/main/res/drawable-hdpi/ic_backup_black_48dp.png b/src/main/res/drawable-hdpi/ic_backup_black_48dp.png new file mode 100644 index 000000000..6506c7236 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_backup_black_48dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_backup_white_48dp.png b/src/main/res/drawable-hdpi/ic_backup_white_48dp.png new file mode 100644 index 000000000..3ff57ad3e Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_backup_white_48dp.png differ diff --git a/src/main/res/drawable-hdpi/ic_cloud_download_white_24dp.png b/src/main/res/drawable-hdpi/ic_cloud_download_white_24dp.png new file mode 100644 index 000000000..4c5d2d049 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_cloud_download_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_backup_black_48dp.png b/src/main/res/drawable-mdpi/ic_backup_black_48dp.png new file mode 100644 index 000000000..81155da52 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_backup_black_48dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_backup_white_48dp.png b/src/main/res/drawable-mdpi/ic_backup_white_48dp.png new file mode 100644 index 000000000..a9602d11b Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_backup_white_48dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_cloud_download_white_24dp.png b/src/main/res/drawable-mdpi/ic_cloud_download_white_24dp.png new file mode 100644 index 000000000..0c6978c1f Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_cloud_download_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_backup_black_48dp.png b/src/main/res/drawable-xhdpi/ic_backup_black_48dp.png new file mode 100644 index 000000000..248289e97 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_backup_black_48dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_backup_white_48dp.png b/src/main/res/drawable-xhdpi/ic_backup_white_48dp.png new file mode 100644 index 000000000..2180f73e8 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_backup_white_48dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_cloud_download_white_24dp.png b/src/main/res/drawable-xhdpi/ic_cloud_download_white_24dp.png new file mode 100644 index 000000000..d1a0573af Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_cloud_download_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_backup_black_48dp.png b/src/main/res/drawable-xxhdpi/ic_backup_black_48dp.png new file mode 100644 index 000000000..932af18ce Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_backup_black_48dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_backup_white_48dp.png b/src/main/res/drawable-xxhdpi/ic_backup_white_48dp.png new file mode 100644 index 000000000..e3a373a9b Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_backup_white_48dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_cloud_download_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_cloud_download_white_24dp.png new file mode 100644 index 000000000..3392d972f Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_cloud_download_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_backup_black_48dp.png b/src/main/res/drawable-xxxhdpi/ic_backup_black_48dp.png new file mode 100644 index 000000000..33d75230c Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_backup_black_48dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_backup_white_48dp.png b/src/main/res/drawable-xxxhdpi/ic_backup_white_48dp.png new file mode 100644 index 000000000..19e40517d Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_backup_white_48dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_cloud_download_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_cloud_download_white_24dp.png new file mode 100644 index 000000000..b24e573ed Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_cloud_download_white_24dp.png differ diff --git a/src/main/res/menu/import_backup.xml b/src/main/res/menu/import_backup.xml new file mode 100644 index 000000000..0d8a14497 --- /dev/null +++ b/src/main/res/menu/import_backup.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/src/main/res/values-es/strings.xml b/src/main/res/values-es/strings.xml index 69312b862..7d74e4c44 100644 --- a/src/main/res/values-es/strings.xml +++ b/src/main/res/values-es/strings.xml @@ -17,6 +17,8 @@ Desbloquear contacto Bloquear dominio Desbloquear dominio + Bloquear participante + Desbloquear participante Gestionar Cuentas Ajustes Compartir con Conversación diff --git a/src/main/res/values-pl/strings.xml b/src/main/res/values-pl/strings.xml index 3c14e2f5a..7bc92e1d9 100644 --- a/src/main/res/values-pl/strings.xml +++ b/src/main/res/values-pl/strings.xml @@ -17,6 +17,8 @@ Odblokuj kontakt Zablokuj domenę Odblokuj domenę + Zablokuj użytkownika + Odblokuj użytkownika Zarządzaj kontami Ustawienia Udostępnij w konwersacji @@ -205,8 +207,8 @@ ID klucza OpenPGP Odcisk OMEMO Odcisk v\\OMEMO - Odcisk OMEMO wiadomości - Odcisk v\\OMEMO wiadomości + Odcisk OMEMO tej wiadomości + Odcisk v\\OMEMO tej wiadomości Pozostałe urządzenia Zaufane odciski OMEMO Pobieranie kluczy... diff --git a/src/main/res/values/attrs.xml b/src/main/res/values/attrs.xml index 2c2acba97..1f78b19a2 100644 --- a/src/main/res/values/attrs.xml +++ b/src/main/res/values/attrs.xml @@ -43,6 +43,9 @@ + + + @@ -63,6 +66,7 @@ + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index b095751f6..3159c119d 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -866,4 +866,11 @@ This looks like a domain address Add anyway This looks like a channel address + Share backup files + Conversations backup + Event + Open backup + The file you selected is not a Conversations backup file + This account has already been setup + Please enter the password for this account diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml index d4a6cc4c2..9ea201428 100644 --- a/src/main/res/values/themes.xml +++ b/src/main/res/values/themes.xml @@ -74,6 +74,7 @@ @drawable/ic_event_black_48dp @drawable/ic_archive_black_48dp @drawable/ic_book_black_48dp + @drawable/ic_backup_black_48dp @drawable/ic_help_black_48dp @drawable/ic_group_add_white_24dp @@ -97,6 +98,7 @@ @drawable/ic_lock_open_white_24dp @drawable/ic_settings_black_24dp @drawable/ic_share_white_24dp + @drawable/ic_cloud_download_white_24dp @drawable/ic_qr_code_scan_white_24dp @drawable/ic_scroll_to_end_black @@ -187,6 +189,7 @@ @drawable/ic_event_white_48dp @drawable/ic_archive_white_48dp @drawable/ic_book_white_48dp + @drawable/ic_backup_white_48dp @drawable/ic_help_white_48dp @drawable/ic_group_add_white_24dp @@ -210,6 +213,7 @@ @drawable/ic_lock_open_white_24dp @drawable/ic_settings_white_24dp @drawable/ic_share_white_24dp + @drawable/ic_cloud_download_white_24dp @drawable/ic_qr_code_scan_white_24dp @drawable/ic_scroll_to_end_white diff --git a/src/playstore/java/eu/siacs/conversations/services/PushManagementService.java b/src/playstore/java/eu/siacs/conversations/services/PushManagementService.java index 2a3d1a648..1fc8e58fe 100644 --- a/src/playstore/java/eu/siacs/conversations/services/PushManagementService.java +++ b/src/playstore/java/eu/siacs/conversations/services/PushManagementService.java @@ -151,7 +151,13 @@ public class PushManagementService { if (!task.isSuccessful()) { Log.d(Config.LOGTAG, "unable to get Firebase instance token", task.getException()); } - final InstanceIdResult result = task.getResult(); + final InstanceIdResult result; + try { + result = task.getResult(); + } catch (Exception e) { + Log.d(Config.LOGTAG, "unable to get Firebase instance token due to bug in library ", e); + return; + } if (result != null) { instanceTokenRetrieved.onGcmInstanceTokenRetrieved(result.getToken()); } diff --git a/src/quicksy/java/eu/siacs/conversations/ui/util/PinEntryWrapper.java b/src/quicksy/java/eu/siacs/conversations/ui/util/PinEntryWrapper.java index 17da72798..8e91f659d 100644 --- a/src/quicksy/java/eu/siacs/conversations/ui/util/PinEntryWrapper.java +++ b/src/quicksy/java/eu/siacs/conversations/ui/util/PinEntryWrapper.java @@ -119,13 +119,19 @@ public class PinEntryWrapper { } } - public void setEnabled(boolean enabled) { - for(EditText digit : digits) { + public void setEnabled(final boolean enabled) { + for (EditText digit : digits) { digit.setEnabled(enabled); digit.setCursorVisible(enabled); digit.setFocusable(enabled); digit.setFocusableInTouchMode(enabled); } + if (enabled) { + final EditText last = digits.get(digits.size() - 1); + if (last.getEditableText().length() > 0) { + last.requestFocus(); + } + } } public boolean isEmpty() { diff --git a/src/quicksy/res/values-es/strings.xml b/src/quicksy/res/values-es/strings.xml index bb9c8d206..2c4a1b0b7 100644 --- a/src/quicksy/res/values-es/strings.xml +++ b/src/quicksy/res/values-es/strings.xml @@ -19,4 +19,5 @@ Quicksy necesita acceder al micrófono Esta categoría de notificación se usa para mostrar una notificación permantente indicando que Quicksy está ejecutándose. Foto de perfil en Quicksy - + Quicksy no está disponible en tu país. + diff --git a/src/quicksy/res/values-pl/strings.xml b/src/quicksy/res/values-pl/strings.xml index b08a73a90..9b9961c6e 100644 --- a/src/quicksy/res/values-pl/strings.xml +++ b/src/quicksy/res/values-pl/strings.xml @@ -19,4 +19,5 @@ Quicksy potrzebuje dostępu do mikrofonu. Ta kategoria powiadomień jest używana do wyświetlania ciągłego powiadomienia o tym, że Quicksy działa. Obrazek profilowy Quicksy - + Quicksy nie jest dostępne w twoim kraju +