diff --git a/build.gradle b/build.gradle index de803bbfcb7aa14ebe0b62724d78ed08e69f6093..700fd54e3ea60e97420a728fc9834f5d927469fe 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,7 @@ dependencies { implementation "androidx.preference:preference:1.2.1" implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.work:work-runtime:2.9.0' implementation "androidx.emoji2:emoji2:1.4.0" freeImplementation "androidx.emoji2:emoji2-bundled:1.4.0" diff --git a/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java b/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java index 4c7387200e56f7a78eb4d06b1ceef2e01b13ee9e..17c76d167d3b7e1fdb22096ec4403eaa5f829d2d 100644 --- a/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java +++ b/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java @@ -37,6 +37,7 @@ import eu.siacs.conversations.persistance.FileBackend; import eu.siacs.conversations.ui.ManageAccountActivity; import eu.siacs.conversations.utils.BackupFileHeader; import eu.siacs.conversations.utils.SerialSingleThreadExecutor; +import eu.siacs.conversations.worker.ExportBackupWorker; import eu.siacs.conversations.xmpp.Jid; import org.bouncycastle.crypto.engines.AESEngine; @@ -273,7 +274,7 @@ public class ImportBackupService extends Service { return false; } - final byte[] key = ExportBackupService.getKey(password, backupFileHeader.getSalt()); + final byte[] key = ExportBackupWorker.getKey(password, backupFileHeader.getSalt()); final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine()); cipher.init( diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index fa89034ae25f7d3afd3e0a1f61e0cc77f86180c3..3d0ce91b02128a98139aee28d4b730b80850928b 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -116,9 +116,9 @@ + android:name="androidx.work.impl.foreground.SystemForegroundService" + android:foregroundServiceType="dataSync" + tools:node="merge" /> requestStorageForBackupLauncher = + registerForActivityResult( + new ActivityResultContracts.RequestPermission(), + isGranted -> { + if (isGranted) { + startOneOffBackup(); + } else { + Toast.makeText( + requireActivity(), + getString( + R.string.no_storage_permission, + getString(R.string.app_name)), + Toast.LENGTH_LONG) + .show(); + } + }); + + @Override + public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) { + setPreferencesFromResource(R.xml.preferences_backup, rootKey); + final var createOneOffBackup = findPreference(CREATE_ONE_OFF_BACKUP); + final ListPreference recurringBackup = findPreference(RECURRING_BACKUP); + final var backupDirectory = findPreference("backup_directory"); + if (createOneOffBackup == null || recurringBackup == null || backupDirectory == null) { + throw new IllegalStateException( + "The preference resource file is missing some preferences"); + } + backupDirectory.setSummary( + getString( + R.string.pref_create_backup_summary, + FileBackend.getBackupDirectory(requireContext()).getAbsolutePath())); + createOneOffBackup.setOnPreferenceClickListener(this::onBackupPreferenceClicked); + final int[] choices = getResources().getIntArray(R.array.recurring_backup_values); + final CharSequence[] entries = new CharSequence[choices.length]; + final CharSequence[] entryValues = new CharSequence[choices.length]; + for (int i = 0; i < choices.length; ++i) { + entryValues[i] = String.valueOf(choices[i]); + entries[i] = timeframeValueToName(requireContext(), choices[i]); + } + recurringBackup.setEntries(entries); + recurringBackup.setEntryValues(entryValues); + recurringBackup.setSummaryProvider(new TimeframeSummaryProvider()); + } + + @Override + protected void onSharedPreferenceChanged(@NonNull String key) { + super.onSharedPreferenceChanged(key); + if (RECURRING_BACKUP.equals(key)) { + final var sharedPreferences = getPreferenceManager().getSharedPreferences(); + if (sharedPreferences == null) { + return; + } + final Long recurringBackupInterval = + Longs.tryParse( + Strings.nullToEmpty( + sharedPreferences.getString(RECURRING_BACKUP, null))); + if (recurringBackupInterval == null) { + return; + } + Log.d( + Config.LOGTAG, + "recurring backup interval changed to: " + recurringBackupInterval); + final var workManager = WorkManager.getInstance(requireContext()); + if (recurringBackupInterval <= 0) { + workManager.cancelUniqueWork(RECURRING_BACKUP); + } else { + final Constraints constraints = + new Constraints.Builder() + .setRequiresBatteryNotLow(true) + .setRequiresStorageNotLow(true) + .build(); + + final PeriodicWorkRequest periodicWorkRequest = + new PeriodicWorkRequest.Builder( + ExportBackupWorker.class, + recurringBackupInterval, + TimeUnit.SECONDS) + .setConstraints(constraints) + .setInputData( + new Data.Builder() + .putBoolean("recurring_backup", true) + .build()) + .build(); + workManager.enqueueUniquePeriodicWork( + RECURRING_BACKUP, ExistingPeriodicWorkPolicy.UPDATE, periodicWorkRequest); + } + } + } + + @Override + public void onStart() { + super.onStart(); + requireActivity().setTitle(R.string.backup); + } + + private boolean onBackupPreferenceClicked(final Preference preference) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + requestStorageForBackupLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE); + } else { + startOneOffBackup(); + } + } else { + startOneOffBackup(); + } + return true; + } + + private void startOneOffBackup() { + final OneTimeWorkRequest exportBackupWorkRequest = + new OneTimeWorkRequest.Builder(ExportBackupWorker.class) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build(); + WorkManager.getInstance(requireContext()) + .enqueueUniqueWork( + CREATE_ONE_OFF_BACKUP, ExistingWorkPolicy.KEEP, exportBackupWorkRequest); + final MaterialAlertDialogBuilder builder = + new MaterialAlertDialogBuilder(requireActivity()); + builder.setMessage(R.string.backup_started_message); + builder.setPositiveButton(R.string.ok, null); + builder.create().show(); + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/fragment/settings/MainSettingsFragment.java b/src/main/java/eu/siacs/conversations/ui/fragment/settings/MainSettingsFragment.java index 049523aa566dfebae1ddca8fe434d9dab628376c..4ab8ade3ceb472b2762017306aacda450ab2e82c 100644 --- a/src/main/java/eu/siacs/conversations/ui/fragment/settings/MainSettingsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/fragment/settings/MainSettingsFragment.java @@ -1,63 +1,27 @@ package eu.siacs.conversations.ui.fragment.settings; -import android.Manifest; -import android.content.Intent; -import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; -import android.widget.Toast; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; -import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.common.base.Strings; import eu.siacs.conversations.BuildConfig; import eu.siacs.conversations.R; -import eu.siacs.conversations.persistance.FileBackend; -import eu.siacs.conversations.services.ExportBackupService; public class MainSettingsFragment extends PreferenceFragmentCompat { - private static final String CREATE_BACKUP = "create_backup"; - - private final ActivityResultLauncher requestStorageForBackupLauncher = - registerForActivityResult( - new ActivityResultContracts.RequestPermission(), - isGranted -> { - if (isGranted) { - startBackup(); - } else { - Toast.makeText( - requireActivity(), - getString( - R.string.no_storage_permission, - getString(R.string.app_name)), - Toast.LENGTH_LONG) - .show(); - } - }); - @Override public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) { setPreferencesFromResource(R.xml.preferences_main, rootKey); final var about = findPreference("about"); final var connection = findPreference("connection"); - final var backup = findPreference(CREATE_BACKUP); - if (about == null || connection == null || backup == null) { + if (about == null || connection == null) { throw new IllegalStateException( "The preference resource file is missing some preferences"); } - backup.setSummary( - getString( - R.string.pref_create_backup_summary, - FileBackend.getBackupDirectory(requireContext()).getAbsolutePath())); - backup.setOnPreferenceClickListener(this::onBackupPreferenceClicked); about.setTitle(getString(R.string.title_activity_about_x, BuildConfig.APP_NAME)); about.setSummary( String.format( @@ -73,31 +37,6 @@ public class MainSettingsFragment extends PreferenceFragmentCompat { } } - private boolean onBackupPreferenceClicked(final Preference preference) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - if (ContextCompat.checkSelfPermission( - requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED) { - requestStorageForBackupLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE); - } else { - startBackup(); - } - } else { - startBackup(); - } - return true; - } - - private void startBackup() { - ContextCompat.startForegroundService( - requireContext(), new Intent(requireContext(), ExportBackupService.class)); - final MaterialAlertDialogBuilder builder = - new MaterialAlertDialogBuilder(requireActivity()); - builder.setMessage(R.string.backup_started_message); - builder.setPositiveButton(R.string.ok, null); - builder.create().show(); - } - @Override public void onStart() { super.onStart(); diff --git a/src/main/java/eu/siacs/conversations/ui/fragment/settings/SecuritySettingsFragment.java b/src/main/java/eu/siacs/conversations/ui/fragment/settings/SecuritySettingsFragment.java index 2890db7ab57c20ee33881d58a46585f64f382fd9..0ccf2679e17d3069093148d73ba31af3dba127fc 100644 --- a/src/main/java/eu/siacs/conversations/ui/fragment/settings/SecuritySettingsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/fragment/settings/SecuritySettingsFragment.java @@ -1,6 +1,5 @@ package eu.siacs.conversations.ui.fragment.settings; -import android.content.Context; import android.content.DialogInterface; import android.os.Bundle; import android.widget.Toast; @@ -13,13 +12,11 @@ import androidx.preference.Preference; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.common.base.Strings; -import com.google.common.primitives.Ints; import eu.siacs.conversations.AppSettings; import eu.siacs.conversations.R; import eu.siacs.conversations.crypto.OmemoSetting; import eu.siacs.conversations.services.MemorizingTrustManager; -import eu.siacs.conversations.utils.TimeFrameUtils; import java.security.KeyStoreException; import java.util.ArrayList; @@ -44,20 +41,13 @@ public class SecuritySettingsFragment extends XmppPreferenceFragment { final CharSequence[] entryValues = new CharSequence[choices.length]; for (int i = 0; i < choices.length; ++i) { entryValues[i] = String.valueOf(choices[i]); - entries[i] = messageDeletionValueToName(requireContext(), choices[i]); + entries[i] = timeframeValueToName(requireContext(), choices[i]); } automaticMessageDeletion.setEntries(entries); automaticMessageDeletion.setEntryValues(entryValues); - automaticMessageDeletion.setSummaryProvider(new MessageDeletionSummaryProvider()); + automaticMessageDeletion.setSummaryProvider(new TimeframeSummaryProvider()); } - private static String messageDeletionValueToName(final Context context, final int value) { - if (value == 0) { - return context.getString(R.string.never); - } else { - return TimeFrameUtils.resolve(context, 1000L * value); - } - } @Override protected void onSharedPreferenceChanged(@NonNull String key) { @@ -161,16 +151,6 @@ public class SecuritySettingsFragment extends XmppPreferenceFragment { .show(); } - private static class MessageDeletionSummaryProvider - implements Preference.SummaryProvider { - - @Nullable - @Override - public CharSequence provideSummary(@NonNull ListPreference preference) { - final Integer value = Ints.tryParse(Strings.nullToEmpty(preference.getValue())); - return messageDeletionValueToName(preference.getContext(), value == null ? 0 : value); - } - } private static class OmemoSummaryProvider implements Preference.SummaryProvider { diff --git a/src/main/java/eu/siacs/conversations/ui/fragment/settings/XmppPreferenceFragment.java b/src/main/java/eu/siacs/conversations/ui/fragment/settings/XmppPreferenceFragment.java index f3b1dabcab1822cde306a4cfe3d8a5b5776b5b1a..76364526d187044915c099dd81dac15a04188ba4 100644 --- a/src/main/java/eu/siacs/conversations/ui/fragment/settings/XmppPreferenceFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/fragment/settings/XmppPreferenceFragment.java @@ -1,15 +1,24 @@ package eu.siacs.conversations.ui.fragment.settings; +import android.content.Context; import android.content.SharedPreferences; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; +import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; +import com.google.common.base.Strings; +import com.google.common.primitives.Ints; + import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.services.XmppConnectionService; import eu.siacs.conversations.ui.XmppActivity; +import eu.siacs.conversations.utils.TimeFrameUtils; public abstract class XmppPreferenceFragment extends PreferenceFragmentCompat { @@ -25,7 +34,7 @@ public abstract class XmppPreferenceFragment extends PreferenceFragmentCompat { }; protected void onSharedPreferenceChanged(@NonNull String key) { - Log.d(Config.LOGTAG,"onSharedPreferenceChanged("+key+")"); + Log.d(Config.LOGTAG, "onSharedPreferenceChanged(" + key + ")"); } public void onBackendConnected() {} @@ -83,4 +92,23 @@ public abstract class XmppPreferenceFragment extends PreferenceFragmentCompat { protected void runOnUiThread(final Runnable runnable) { requireActivity().runOnUiThread(runnable); } + + protected static String timeframeValueToName(final Context context, final int value) { + if (value == 0) { + return context.getString(R.string.never); + } else { + return TimeFrameUtils.resolve(context, 1000L * value); + } + } + + protected static class TimeframeSummaryProvider + implements Preference.SummaryProvider { + + @Nullable + @Override + public CharSequence provideSummary(@NonNull ListPreference preference) { + final Integer value = Ints.tryParse(Strings.nullToEmpty(preference.getValue())); + return timeframeValueToName(preference.getContext(), value == null ? 0 : value); + } + } } diff --git a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java b/src/main/java/eu/siacs/conversations/utils/MimeUtils.java index 6273e2b43cd45b36b3eb737afffc737855dc2f4e..ce919897ec4326df8269944a76b45e4a90e90d9a 100644 --- a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java +++ b/src/main/java/eu/siacs/conversations/utils/MimeUtils.java @@ -37,7 +37,7 @@ import java.util.Properties; import eu.siacs.conversations.Config; import eu.siacs.conversations.entities.Transferable; -import eu.siacs.conversations.services.ExportBackupService; +import eu.siacs.conversations.worker.ExportBackupWorker; /** * Utilities for dealing with MIME types. @@ -91,7 +91,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(ExportBackupWorker.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 e1996440cfff7c913e54e1315e00df8baebf5042..245f196a66a70b16bbfc02d567370e947a9c9010 100644 --- a/src/main/java/eu/siacs/conversations/utils/UIHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/UIHelper.java @@ -16,8 +16,6 @@ import androidx.core.content.ContextCompat; import com.google.android.material.color.MaterialColors; import com.google.common.base.Strings; -import java.math.BigInteger; -import java.security.MessageDigest; import java.util.Arrays; import java.util.Calendar; import java.util.Date; @@ -31,14 +29,13 @@ import eu.siacs.conversations.entities.Account; import eu.siacs.conversations.entities.Contact; import eu.siacs.conversations.entities.Conversation; import eu.siacs.conversations.entities.Conversational; -import eu.siacs.conversations.entities.ListItem; import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.entities.MucOptions; import eu.siacs.conversations.entities.Presence; import eu.siacs.conversations.entities.RtpSessionStatus; import eu.siacs.conversations.entities.Transferable; -import eu.siacs.conversations.services.ExportBackupService; import eu.siacs.conversations.ui.util.QuoteHelper; +import eu.siacs.conversations.worker.ExportBackupWorker; import eu.siacs.conversations.xmpp.Jid; public class UIHelper { @@ -410,7 +407,7 @@ 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)) { + } else if (mime.equals(ExportBackupWorker.MIME_TYPE)) { return context.getString(R.string.conversations_backup); } else if (mime.contains("vcard")) { return context.getString(R.string.vcard); diff --git a/src/main/java/eu/siacs/conversations/services/ExportBackupService.java b/src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java similarity index 51% rename from src/main/java/eu/siacs/conversations/services/ExportBackupService.java rename to src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java index 1a7ac070e9380038dfdfe8998330eebff525676f..167a1f240aa5f6511217118df3f31b75f0ff5d68 100644 --- a/src/main/java/eu/siacs/conversations/services/ExportBackupService.java +++ b/src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java @@ -1,67 +1,71 @@ -package eu.siacs.conversations.services; +package eu.siacs.conversations.worker; import static eu.siacs.conversations.utils.Compatibility.s; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; -import android.app.Service; import android.content.Context; import android.content.Intent; +import android.content.pm.ServiceInfo; import android.database.Cursor; -import android.database.DatabaseUtils; import android.database.sqlite.SQLiteDatabase; import android.net.Uri; -import android.os.IBinder; import android.util.Log; +import androidx.annotation.NonNull; import androidx.core.app.NotificationCompat; +import androidx.work.ForegroundInfo; +import androidx.work.Worker; +import androidx.work.WorkerParameters; -import com.google.common.base.CharMatcher; +import com.google.common.base.Optional; import com.google.common.base.Strings; import com.google.gson.stream.JsonWriter; +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; + import java.io.DataOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStreamWriter; -import java.io.PrintWriter; import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; import java.security.SecureRandom; import java.security.spec.InvalidKeySpecException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Locale; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.zip.GZIPOutputStream; import javax.crypto.Cipher; import javax.crypto.CipherOutputStream; +import javax.crypto.NoSuchPaddingException; 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 class ExportBackupWorker extends Worker { - private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.US); + private static final SimpleDateFormat DATE_FORMAT = + new SimpleDateFormat("yyyy-MM-dd", Locale.US); public static final String KEYTYPE = "AES"; public static final String CIPHERMODE = "AES/GCM/NoPadding"; @@ -70,211 +74,80 @@ public class ExportBackupService extends Service { public static final String MIME_TYPE = "application/vnd.conversations.backup"; private static final int NOTIFICATION_ID = 19; - private static final AtomicBoolean RUNNING = new AtomicBoolean(false); - private DatabaseBackend mDatabaseBackend; - private List mAccounts; - private NotificationManager notificationManager; + private static final int BACKUP_CREATED_NOTIFICATION_ID = 23; - private static List getPossibleFileOpenIntents( - final Context context, final String path) { + private final boolean recurringBackup; - // http://www.openintents.org/action/android-intent-action-view/file-directory - // do not use 'vnd.android.document/directory' since this will trigger system file manager - final Intent openIntent = new Intent(Intent.ACTION_VIEW); - openIntent.addCategory(Intent.CATEGORY_DEFAULT); - if (Compatibility.runsAndTargetsTwentyFour(context)) { - openIntent.setType("resource/folder"); - } else { - openIntent.setDataAndType(Uri.parse("file://" + path), "resource/folder"); - } - openIntent.putExtra("org.openintents.extra.ABSOLUTE_PATH", path); - - final Intent amazeIntent = new Intent(Intent.ACTION_VIEW); - amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder"); - - // will open a file manager at root and user can navigate themselves - final Intent systemFallBack = new Intent(Intent.ACTION_VIEW); - systemFallBack.addCategory(Intent.CATEGORY_DEFAULT); - systemFallBack.setData( - Uri.parse("content://com.android.externalstorage.documents/root/primary")); - - return Arrays.asList(openIntent, amazeIntent, systemFallBack); + public ExportBackupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) { + super(context, workerParams); + final var inputData = workerParams.getInputData(); + this.recurringBackup = inputData.getBoolean("recurring_backup", false); } - private static void accountExport( - final SQLiteDatabase db, final String uuid, final JsonWriter writer) - throws IOException { - final Cursor accountCursor = - db.query( - Account.TABLENAME, - null, - Account.UUID + "=?", - new String[] {uuid}, - null, - null, - null); - while (accountCursor != null && accountCursor.moveToNext()) { - writer.beginObject(); - writer.name("table"); - writer.value(Account.TABLENAME); - writer.name("values"); - writer.beginObject(); - for (int i = 0; i < accountCursor.getColumnCount(); ++i) { - final String name = accountCursor.getColumnName(i); - writer.name(name); - final String value = accountCursor.getString(i); - if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) { - writer.nullValue(); - } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i)) - && value.matches("\\d+")) { - int intValue = Integer.parseInt(value); - intValue |= 1 << Account.OPTION_DISABLED; - writer.value(intValue); - } else { - writer.value(value); - } - } - writer.endObject(); - writer.endObject(); - } - if (accountCursor != null) { - accountCursor.close(); - } - } - - private static void simpleExport( - final SQLiteDatabase db, - final String table, - final String column, - final String uuid, - final JsonWriter writer) - throws IOException { - final Cursor cursor = - db.query(table, null, column + "=?", new String[] {uuid}, null, null, null); - while (cursor != null && cursor.moveToNext()) { - writer.beginObject(); - writer.name("table"); - writer.value(table); - writer.name("values"); - writer.beginObject(); - for (int i = 0; i < cursor.getColumnCount(); ++i) { - final String name = cursor.getColumnName(i); - writer.name(name); - final String value = cursor.getString(i); - writer.value(value); - } - writer.endObject(); - writer.endObject(); - } - if (cursor != null) { - cursor.close(); - } - } - - public static byte[] getKey(final String password, final byte[] salt) - throws InvalidKeySpecException { - final SecretKeyFactory factory; + @NonNull + @Override + public Result doWork() { + final List files; try { - factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException(e); + files = export(); + } catch (final IOException + | InvalidKeySpecException + | InvalidAlgorithmParameterException + | InvalidKeyException + | NoSuchPaddingException + | NoSuchAlgorithmException + | NoSuchProviderException e) { + Log.d(Config.LOGTAG, "could not create backup", e); + return Result.failure(); } - return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)) - .getEncoded(); - } - - @Override - public void onCreate() { - mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext()); - mAccounts = mDatabaseBackend.getAccounts(); - notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + Log.d(Config.LOGTAG, "done creating " + files.size() + " backup files"); + getApplicationContext().getSystemService(NotificationManager.class).cancel(NOTIFICATION_ID); + if (files.isEmpty() || recurringBackup) { + return Result.success(); + } + notifySuccess(files); + return Result.success(); } + @NonNull @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (RUNNING.compareAndSet(false, true)) { - new Thread( - () -> { - boolean success; - List files; - try { - files = export(); - success = true; - } catch (final Exception e) { - Log.d(Config.LOGTAG, "unable to create backup", e); - success = false; - files = Collections.emptyList(); - } - stopForeground(true); - RUNNING.set(false); - if (success) { - notifySuccess(files); - } - stopSelf(); - }) - .start(); - return START_STICKY; + public ForegroundInfo getForegroundInfo() { + Log.d(Config.LOGTAG, "getForegroundInfo()"); + final var context = getApplicationContext(); + final NotificationCompat.Builder notification = + new NotificationCompat.Builder(context, "backup"); + notification + .setContentTitle(context.getString(R.string.notification_create_backup_title)) + .setSmallIcon(R.drawable.ic_archive_24dp) + .setProgress(1, 0, false); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + return new ForegroundInfo( + NOTIFICATION_ID, + notification.build(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); } else { - Log.d( - Config.LOGTAG, - "ExportBackupService. ignoring start command because already running"); + return new ForegroundInfo(NOTIFICATION_ID, notification.build()); } - return START_NOT_STICKY; } - private void messageExport( - final SQLiteDatabase db, - final String uuid, - final JsonWriter writer, - final Progress progress) - throws IOException { - 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 for account " + uuid); - int i = 0; - int p = 0; - while (cursor != null && cursor.moveToNext()) { - writer.beginObject(); - writer.name("table"); - writer.value(Message.TABLENAME); - writer.name("values"); - writer.beginObject(); - for (int j = 0; j < cursor.getColumnCount(); ++j) { - final String name = cursor.getColumnName(j); - writer.name(name); - final String value = cursor.getString(j); - writer.value(value); - } - writer.endObject(); - writer.endObject(); - final int percentage = i * 100 / size; - if (p < percentage) { - p = percentage; - notificationManager.notify(NOTIFICATION_ID, progress.build(p)); - } - i++; - } - if (cursor != null) { - cursor.close(); - } - } + private List export() + throws IOException, + InvalidKeySpecException, + InvalidAlgorithmParameterException, + InvalidKeyException, + NoSuchPaddingException, + NoSuchAlgorithmException, + NoSuchProviderException { + final Context context = getApplicationContext(); + final var database = DatabaseBackend.getInstance(context); + final var accounts = database.getAccounts(); - 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_24dp) - .setProgress(1, 0, false); - startForeground(NOTIFICATION_ID, mBuilder.build()); int count = 0; - final int max = this.mAccounts.size(); + final int max = accounts.size(); final SecureRandom secureRandom = new SecureRandom(); final List files = new ArrayList<>(); Log.d(Config.LOGTAG, "starting backup for " + max + " accounts"); - for (final Account account : this.mAccounts) { + for (final Account account : accounts) { final String password = account.getPassword(); if (Strings.nullToEmpty(password).trim().isEmpty()) { Log.d( @@ -295,20 +168,24 @@ public class ExportBackupService extends Service { secureRandom.nextBytes(salt); final BackupFileHeader backupFileHeader = new BackupFileHeader( - getString(R.string.app_name), + context.getString(R.string.app_name), account.getJid(), System.currentTimeMillis(), IV, salt); - final Progress progress = new Progress(mBuilder, max, count); + final NotificationCompat.Builder notification = + new NotificationCompat.Builder(context, "backup"); + notification + .setContentTitle(context.getString(R.string.notification_create_backup_title)) + .setSmallIcon(R.drawable.ic_archive_24dp) + .setProgress(1, 0, false); + final Progress progress = new Progress(notification, max, count); final String filename = String.format( "%s.%s.ceb", account.getJid().asBareJid().toEscapedString(), DATE_FORMAT.format(new Date())); - final File file = - new File( - FileBackend.getBackupDirectory(this), filename); + final File file = new File(FileBackend.getBackupDirectory(context), filename); files.add(file); final File directory = file.getParentFile(); if (directory != null && directory.mkdirs()) { @@ -335,7 +212,7 @@ public class ExportBackupService extends Service { new JsonWriter( new OutputStreamWriter(gzipOutputStream, StandardCharsets.UTF_8)); jsonWriter.beginArray(); - final SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase(); + final SQLiteDatabase db = database.getReadableDatabase(); final String uuid = account.getUuid(); accountExport(db, uuid, jsonWriter); simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, jsonWriter); @@ -361,96 +238,240 @@ public class ExportBackupService extends Service { private void mediaScannerScanFile(final File file) { final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); intent.setData(Uri.fromFile(file)); - sendBroadcast(intent); + getApplicationContext().sendBroadcast(intent); } - private void notifySuccess(final List files) { - final String path = FileBackend.getBackupDirectory(this).getAbsolutePath(); - - PendingIntent openFolderIntent = null; + private static void accountExport( + final SQLiteDatabase db, final String uuid, final JsonWriter writer) + throws IOException { + try (final Cursor accountCursor = + db.query( + Account.TABLENAME, + null, + Account.UUID + "=?", + new String[] {uuid}, + null, + null, + null)) { + while (accountCursor != null && accountCursor.moveToNext()) { + writer.beginObject(); + writer.name("table"); + writer.value(Account.TABLENAME); + writer.name("values"); + writer.beginObject(); + for (int i = 0; i < accountCursor.getColumnCount(); ++i) { + final String name = accountCursor.getColumnName(i); + writer.name(name); + final String value = accountCursor.getString(i); + if (value == null + || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) { + writer.nullValue(); + } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i)) + && value.matches("\\d+")) { + int intValue = Integer.parseInt(value); + intValue |= 1 << Account.OPTION_DISABLED; + writer.value(intValue); + } else { + writer.value(value); + } + } + writer.endObject(); + writer.endObject(); + } + } + } - for (final Intent intent : getPossibleFileOpenIntents(this, path)) { - if (intent.resolveActivityInfo(getPackageManager(), 0) != null) { - openFolderIntent = - PendingIntent.getActivity( - this, - 189, - intent, - s() - ? PendingIntent.FLAG_IMMUTABLE - | PendingIntent.FLAG_UPDATE_CURRENT - : PendingIntent.FLAG_UPDATE_CURRENT); - break; + private static void simpleExport( + final SQLiteDatabase db, + final String table, + final String column, + final String uuid, + final JsonWriter writer) + throws IOException { + try (final Cursor cursor = + db.query(table, null, column + "=?", new String[] {uuid}, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + writer.beginObject(); + writer.name("table"); + writer.value(table); + writer.name("values"); + writer.beginObject(); + for (int i = 0; i < cursor.getColumnCount(); ++i) { + final String name = cursor.getColumnName(i); + writer.name(name); + final String value = cursor.getString(i); + writer.value(value); + } + writer.endObject(); + writer.endObject(); } } + } - 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)); + private void messageExport( + final SQLiteDatabase db, + final String uuid, + final JsonWriter writer, + final Progress progress) + throws IOException { + final var notificationManager = + getApplicationContext().getSystemService(NotificationManager.class); + try (final Cursor cursor = + db.rawQuery( + "select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", + new String[] {uuid})) { + final int size = cursor != null ? cursor.getCount() : 0; + Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid); + int i = 0; + int p = 0; + while (cursor != null && cursor.moveToNext()) { + writer.beginObject(); + writer.name("table"); + writer.value(Message.TABLENAME); + writer.name("values"); + writer.beginObject(); + for (int j = 0; j < cursor.getColumnCount(); ++j) { + final String name = cursor.getColumnName(j); + writer.name(name); + final String value = cursor.getString(j); + writer.value(value); + } + writer.endObject(); + writer.endObject(); + final int percentage = i * 100 / size; + if (p < percentage) { + p = percentage; + notificationManager.notify(NOTIFICATION_ID, progress.build(p)); + } + i++; } - 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, - s() - ? PendingIntent.FLAG_IMMUTABLE - | PendingIntent.FLAG_UPDATE_CURRENT - : 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)) + public static byte[] getKey(final String password, final byte[] salt) + throws InvalidKeySpecException { + final SecretKeyFactory factory; + try { + factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)) + .getEncoded(); + } + + private void notifySuccess(final List files) { + final var context = getApplicationContext(); + final String path = FileBackend.getBackupDirectory(context).getAbsolutePath(); + + final var openFolderIntent = getOpenFolderIntent(path); + + final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE); + final ArrayList uris = new ArrayList<>(); + for (final File file : files) { + uris.add(FileBackend.getUriForFile(context, 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, context.getString(R.string.share_backup_files)); + final var shareFilesIntent = + PendingIntent.getActivity( + context, + 190, + chooser, + s() + ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT + : PendingIntent.FLAG_UPDATE_CURRENT); + + NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, "backup"); + mBuilder.setContentTitle(context.getString(R.string.notification_backup_created_title)) + .setContentText( + context.getString(R.string.notification_backup_created_subtitle, path)) .setStyle( new NotificationCompat.BigTextStyle() .bigText( - getString( + context.getString( R.string.notification_backup_created_subtitle, - FileBackend.getBackupDirectory(this) + FileBackend.getBackupDirectory(context) .getAbsolutePath()))) .setAutoCancel(true) - .setContentIntent(openFolderIntent) .setSmallIcon(R.drawable.ic_archive_24dp); - if (shareFilesIntent != null) { - mBuilder.addAction( - R.drawable.ic_share_24dp, - getString(R.string.share_backup_files), - shareFilesIntent); + if (openFolderIntent.isPresent()) { + mBuilder.setContentIntent(openFolderIntent.get()); + } else { + Log.w(Config.LOGTAG, "no app can display folders"); } - notificationManager.notify(NOTIFICATION_ID, mBuilder.build()); + mBuilder.addAction( + R.drawable.ic_share_24dp, + context.getString(R.string.share_backup_files), + shareFilesIntent); + final var notificationManager = context.getSystemService(NotificationManager.class); + notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, mBuilder.build()); } - @Override - public IBinder onBind(Intent intent) { - return null; + private Optional getOpenFolderIntent(final String path) { + final var context = getApplicationContext(); + for (final Intent intent : getPossibleFileOpenIntents(context, path)) { + if (intent.resolveActivityInfo(context.getPackageManager(), 0) != null) { + return Optional.of( + PendingIntent.getActivity( + context, + 189, + intent, + s() + ? PendingIntent.FLAG_IMMUTABLE + | PendingIntent.FLAG_UPDATE_CURRENT + : PendingIntent.FLAG_UPDATE_CURRENT)); + } + } + return Optional.absent(); + } + + private static List getPossibleFileOpenIntents( + final Context context, final String path) { + + // http://www.openintents.org/action/android-intent-action-view/file-directory + // do not use 'vnd.android.document/directory' since this will trigger system file manager + final Intent openIntent = new Intent(Intent.ACTION_VIEW); + openIntent.addCategory(Intent.CATEGORY_DEFAULT); + if (Compatibility.runsAndTargetsTwentyFour(context)) { + openIntent.setType("resource/folder"); + } else { + openIntent.setDataAndType(Uri.parse("file://" + path), "resource/folder"); + } + openIntent.putExtra("org.openintents.extra.ABSOLUTE_PATH", path); + + final Intent amazeIntent = new Intent(Intent.ACTION_VIEW); + amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder"); + + // will open a file manager at root and user can navigate themselves + final Intent systemFallBack = new Intent(Intent.ACTION_VIEW); + systemFallBack.addCategory(Intent.CATEGORY_DEFAULT); + systemFallBack.setData( + Uri.parse("content://com.android.externalstorage.documents/root/primary")); + + return Arrays.asList(openIntent, amazeIntent, systemFallBack); } private static class Progress { - private final NotificationCompat.Builder builder; + private final NotificationCompat.Builder notification; private final int max; private final int count; - private Progress(NotificationCompat.Builder builder, int max, int count) { - this.builder = builder; + private Progress( + final NotificationCompat.Builder notification, final int max, final int count) { + this.notification = notification; this.max = max; this.count = count; } private Notification build(int percentage) { - builder.setProgress(max * 100, count * 100 + percentage, false); - return builder.build(); + notification.setProgress(max * 100, count * 100 + percentage, false); + return notification.build(); } } } diff --git a/src/main/res/drawable/ic_calendar_month_24dp.xml b/src/main/res/drawable/ic_calendar_month_24dp.xml new file mode 100644 index 0000000000000000000000000000000000000000..007f1fa45f442d9ecc6a49cae3053f05b6c43c4c --- /dev/null +++ b/src/main/res/drawable/ic_calendar_month_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/src/main/res/values/arrays.xml b/src/main/res/values/arrays.xml index 6dc8caca69bbeaf44a7b87b0b65df2179f2e8d5b..1c00e9e737efd498898eee6e120dd19477d260d7 100644 --- a/src/main/res/values/arrays.xml +++ b/src/main/res/values/arrays.xml @@ -84,6 +84,13 @@ 2592000 15811200 + + 0 + 86400 + 172800 + 604800 + 2592000 + always default_on diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index bb1e1120fd86b47441b1d8f2392eba462f6f4f29..356e828cf2a6327cd82f962d8508739ab76db914 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1059,5 +1059,8 @@ Increase font size in message bubbles Invites from strangers Accept invites to group chats from strangers + Create one-off, Schedule recurring + Create one-off backup + Recurring backup diff --git a/src/main/res/xml/preferences_backup.xml b/src/main/res/xml/preferences_backup.xml new file mode 100644 index 0000000000000000000000000000000000000000..b0362c7856b02349c266f04c63359e97e9634a51 --- /dev/null +++ b/src/main/res/xml/preferences_backup.xml @@ -0,0 +1,20 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/xml/preferences_main.xml b/src/main/res/xml/preferences_main.xml index 2de980c40f94446a1b1420b0dd3d11427ddefecb..013f2506cd98a685155e200d0eae5dc16a032ec5 100644 --- a/src/main/res/xml/preferences_main.xml +++ b/src/main/res/xml/preferences_main.xml @@ -34,9 +34,10 @@ app:title="@string/pref_connection_options" /> + android:key="backup" + app:fragment="eu.siacs.conversations.ui.fragment.settings.BackupSettingsFragment" + android:summary="@string/pref_backup_summary" + android:title="@string/backup" />