mirror of
https://github.com/oxen-io/session-android.git
synced 2025-03-11 09:10:53 +00:00
Merge pull request #375 from metaphore/backup-restore
Backup and restore
This commit is contained in:
commit
32bb707fa3
@ -105,6 +105,11 @@
|
|||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
|
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
|
||||||
|
<activity
|
||||||
|
android:name="org.thoughtcrime.securesms.loki.activities.BackupRestoreActivity"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
|
||||||
<activity
|
<activity
|
||||||
android:name="org.thoughtcrime.securesms.loki.activities.LinkDeviceActivity"
|
android:name="org.thoughtcrime.securesms.loki.activities.LinkDeviceActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
|
18
build.gradle
18
build.gradle
@ -89,9 +89,12 @@ dependencies {
|
|||||||
implementation 'androidx.multidex:multidex:2.0.1'
|
implementation 'androidx.multidex:multidex:2.0.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.2.0'
|
implementation 'androidx.lifecycle:lifecycle-common-java8:2.2.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0'
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0'
|
||||||
implementation "androidx.work:work-runtime-ktx:2.4.0"
|
implementation 'androidx.activity:activity-ktx:1.1.0'
|
||||||
|
implementation 'androidx.fragment:fragment-ktx:1.3.0-beta01'
|
||||||
implementation "androidx.core:core-ktx:1.3.2"
|
implementation "androidx.core:core-ktx:1.3.2"
|
||||||
|
implementation "androidx.work:work-runtime-ktx:2.4.0"
|
||||||
|
|
||||||
implementation ("com.google.firebase:firebase-messaging:18.0.0") {
|
implementation ("com.google.firebase:firebase-messaging:18.0.0") {
|
||||||
exclude group: 'com.google.firebase', module: 'firebase-core'
|
exclude group: 'com.google.firebase', module: 'firebase-core'
|
||||||
@ -364,6 +367,19 @@ android {
|
|||||||
includeAndroidResources = true
|
includeAndroidResources = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
dataBinding true
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '1.8'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -49,6 +49,16 @@
|
|||||||
android:layout_marginRight="@dimen/massive_spacing"
|
android:layout_marginRight="@dimen/massive_spacing"
|
||||||
android:text="@string/activity_landing_restore_button_title" />
|
android:text="@string/activity_landing_restore_button_title" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
style="@style/Widget.Session.Button.Common.ProminentOutline"
|
||||||
|
android:id="@+id/restoreBackupButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="@dimen/medium_button_height"
|
||||||
|
android:layout_marginLeft="@dimen/massive_spacing"
|
||||||
|
android:layout_marginTop="@dimen/medium_spacing"
|
||||||
|
android:layout_marginRight="@dimen/massive_spacing"
|
||||||
|
android:text="@string/activity_landing_restore_backup_button_title" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/linkButton"
|
android:id="@+id/linkButton"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
124
res/layout/activity_backup_restore.xml
Normal file
124
res/layout/activity_backup_restore.xml
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<layout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<data>
|
||||||
|
<import type="org.thoughtcrime.securesms.loki.activities.BackupRestoreViewModel"/>
|
||||||
|
<import type="org.thoughtcrime.securesms.util.BackupUtil"/>
|
||||||
|
<import type="android.view.View"/>
|
||||||
|
<variable
|
||||||
|
name="viewModel"
|
||||||
|
type="org.thoughtcrime.securesms.loki.activities.BackupRestoreViewModel" />
|
||||||
|
</data>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="@dimen/very_large_spacing"
|
||||||
|
android:layout_marginRight="@dimen/very_large_spacing"
|
||||||
|
android:text="@string/activity_backup_restore_title"
|
||||||
|
android:textColor="@color/text"
|
||||||
|
android:textSize="@dimen/large_font_size"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="@dimen/very_large_spacing"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:layout_marginRight="@dimen/very_large_spacing"
|
||||||
|
android:text="@string/activity_backup_restore_explanation_1"
|
||||||
|
android:textColor="@color/text"
|
||||||
|
android:textSize="@dimen/small_font_size" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonSelectFile"
|
||||||
|
style="@style/Button.Primary"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="@dimen/very_large_spacing"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:layout_marginRight="@dimen/very_large_spacing"
|
||||||
|
android:textColor="@color/black"
|
||||||
|
android:text="@{viewModel.backupFile != null ? BackupRestoreViewModel.uriToFileName(buttonSelectFile, viewModel.backupFile) : @string/activity_backup_restore_select_file}"
|
||||||
|
tools:text="Select a file"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/backupCode"
|
||||||
|
style="@style/SmallSessionEditText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginLeft="@dimen/very_large_spacing"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:layout_marginRight="@dimen/very_large_spacing"
|
||||||
|
android:hint="@string/activity_backup_restore_passphrase"
|
||||||
|
android:inputType="numberDecimal|textNoSuggestions"
|
||||||
|
android:digits="0123456789"
|
||||||
|
android:maxLength="@{BackupUtil.BACKUP_PASSPHRASE_LENGTH}"
|
||||||
|
android:text="@={viewModel.backupPassphrase}"
|
||||||
|
android:visibility="@{viewModel.backupFile != null ? View.VISIBLE : View.INVISIBLE}"/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/restoreButton"
|
||||||
|
style="@style/Widget.Session.Button.Common.ProminentFilled"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="@dimen/medium_button_height"
|
||||||
|
android:layout_marginLeft="@dimen/massive_spacing"
|
||||||
|
android:layout_marginRight="@dimen/massive_spacing"
|
||||||
|
android:text="@string/continue_2"
|
||||||
|
android:visibility="@{BackupRestoreViewModel.validateData(viewModel.backupFile, viewModel.backupPassphrase) ? View.VISIBLE : View.INVISIBLE}"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/termsTextView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="@dimen/onboarding_button_bottom_offset"
|
||||||
|
android:layout_marginLeft="@dimen/massive_spacing"
|
||||||
|
android:layout_marginRight="@dimen/massive_spacing"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="By using this service, you agree to our Terms of Service and Privacy Policy"
|
||||||
|
android:textColor="@color/text"
|
||||||
|
android:textColorLink="@color/text"
|
||||||
|
android:textSize="@dimen/very_small_font_size" /> <!-- Intentionally not yet translated -->
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/busyIndicator"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="#A4000000"
|
||||||
|
android:visibility="@{viewModel.processingBackupFile == true ? View.VISIBLE : View.GONE}"
|
||||||
|
tools:visibility="visible">
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:layout_width="64dp"
|
||||||
|
android:layout_height="64dp"
|
||||||
|
android:indeterminate="true"
|
||||||
|
android:layout_gravity="center"/>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</layout>
|
@ -49,6 +49,16 @@
|
|||||||
android:layout_marginRight="@dimen/massive_spacing"
|
android:layout_marginRight="@dimen/massive_spacing"
|
||||||
android:text="@string/activity_landing_restore_button_title" />
|
android:text="@string/activity_landing_restore_button_title" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
style="@style/Widget.Session.Button.Common.ProminentOutline"
|
||||||
|
android:id="@+id/restoreBackupButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="@dimen/medium_button_height"
|
||||||
|
android:layout_marginLeft="@dimen/massive_spacing"
|
||||||
|
android:layout_marginTop="@dimen/small_spacing"
|
||||||
|
android:layout_marginRight="@dimen/massive_spacing"
|
||||||
|
android:text="@string/activity_landing_restore_backup_button_title" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/linkButton"
|
android:id="@+id/linkButton"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
@ -1545,6 +1545,7 @@
|
|||||||
<string name="RegistrationLockDialog_disable_registration_lock_pin">Disable Registration Lock PIN?</string>
|
<string name="RegistrationLockDialog_disable_registration_lock_pin">Disable Registration Lock PIN?</string>
|
||||||
<string name="RegistrationLockDialog_disable">Disable</string>
|
<string name="RegistrationLockDialog_disable">Disable</string>
|
||||||
<string name="preferences_chats__backups">Backups</string>
|
<string name="preferences_chats__backups">Backups</string>
|
||||||
|
<string name="preferences_chats__backup_export_error">An error occurred during exporting a backup. Please try again later.</string>
|
||||||
<string name="prompt_passphrase_activity__signal_is_locked">Session is Locked</string>
|
<string name="prompt_passphrase_activity__signal_is_locked">Session is Locked</string>
|
||||||
<string name="prompt_passphrase_activity__tap_to_unlock">TAP TO UNLOCK</string>
|
<string name="prompt_passphrase_activity__tap_to_unlock">TAP TO UNLOCK</string>
|
||||||
<string name="RegistrationLockDialog_reminder">Reminder:</string>
|
<string name="RegistrationLockDialog_reminder">Reminder:</string>
|
||||||
@ -1669,6 +1670,7 @@
|
|||||||
<string name="activity_landing_title_2">Your Session begins here...</string>
|
<string name="activity_landing_title_2">Your Session begins here...</string>
|
||||||
<string name="activity_landing_register_button_title">Create Session ID</string>
|
<string name="activity_landing_register_button_title">Create Session ID</string>
|
||||||
<string name="activity_landing_restore_button_title">Continue Your Session</string>
|
<string name="activity_landing_restore_button_title">Continue Your Session</string>
|
||||||
|
<string name="activity_landing_restore_backup_button_title">Restore Backup</string>
|
||||||
<string name="activity_landing_link_button_title">Link to an existing account</string>
|
<string name="activity_landing_link_button_title">Link to an existing account</string>
|
||||||
<string name="activity_landing_device_unlinked_dialog_title">Your device was unlinked successfully</string>
|
<string name="activity_landing_device_unlinked_dialog_title">Your device was unlinked successfully</string>
|
||||||
|
|
||||||
@ -1868,4 +1870,9 @@
|
|||||||
|
|
||||||
<string name="dialog_backup_activation_failed">Failed to activate backups. Please try again or contact support.</string>
|
<string name="dialog_backup_activation_failed">Failed to activate backups. Please try again or contact support.</string>
|
||||||
|
|
||||||
|
<string name="activity_backup_restore_title">Restore backup</string>
|
||||||
|
<string name="activity_backup_restore_select_file">Select a file</string>
|
||||||
|
<string name="activity_backup_restore_explanation_1">Select a backup file and enter the passphrase it was created with.</string>
|
||||||
|
<string name="activity_backup_restore_passphrase">30-digit passphrase</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
<org.thoughtcrime.securesms.components.SwitchPreferenceCompat
|
<org.thoughtcrime.securesms.components.SwitchPreferenceCompat
|
||||||
android:defaultValue="false"
|
android:defaultValue="false"
|
||||||
android:key="pref_backup_enabled_v2"
|
android:key="pref_backup_enabled_v3"
|
||||||
android:title="@string/preferences_chats__chat_backups"
|
android:title="@string/preferences_chats__chat_backups"
|
||||||
android:summary="@string/preferences_chats__backup_chats_to_external_storage" />
|
android:summary="@string/preferences_chats__backup_chats_to_external_storage" />
|
||||||
|
|
||||||
@ -102,7 +102,7 @@
|
|||||||
android:key="pref_backup_create"
|
android:key="pref_backup_create"
|
||||||
android:title="@string/preferences_chats__create_backup"
|
android:title="@string/preferences_chats__create_backup"
|
||||||
android:persistent="false"
|
android:persistent="false"
|
||||||
android:dependency="pref_backup_enabled_v2"
|
android:dependency="pref_backup_enabled_v3"
|
||||||
tools:summary="Last backup: 3 days ago" />
|
tools:summary="Last backup: 3 days ago" />
|
||||||
|
|
||||||
</PreferenceCategory>
|
</PreferenceCategory>
|
||||||
|
@ -66,6 +66,7 @@ import org.thoughtcrime.securesms.loki.api.BackgroundPollWorker;
|
|||||||
import org.thoughtcrime.securesms.loki.api.ClosedGroupPoller;
|
import org.thoughtcrime.securesms.loki.api.ClosedGroupPoller;
|
||||||
import org.thoughtcrime.securesms.loki.api.LokiPushNotificationManager;
|
import org.thoughtcrime.securesms.loki.api.LokiPushNotificationManager;
|
||||||
import org.thoughtcrime.securesms.loki.api.PublicChatManager;
|
import org.thoughtcrime.securesms.loki.api.PublicChatManager;
|
||||||
|
import org.thoughtcrime.securesms.loki.api.ResetThreadSessionJob;
|
||||||
import org.thoughtcrime.securesms.loki.database.LokiAPIDatabase;
|
import org.thoughtcrime.securesms.loki.database.LokiAPIDatabase;
|
||||||
import org.thoughtcrime.securesms.loki.database.LokiThreadDatabase;
|
import org.thoughtcrime.securesms.loki.database.LokiThreadDatabase;
|
||||||
import org.thoughtcrime.securesms.loki.database.LokiUserDatabase;
|
import org.thoughtcrime.securesms.loki.database.LokiUserDatabase;
|
||||||
|
@ -5,6 +5,7 @@ import android.animation.Animator;
|
|||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
@ -34,7 +35,7 @@ import org.greenrobot.eventbus.EventBus;
|
|||||||
import org.greenrobot.eventbus.Subscribe;
|
import org.greenrobot.eventbus.Subscribe;
|
||||||
import org.greenrobot.eventbus.ThreadMode;
|
import org.greenrobot.eventbus.ThreadMode;
|
||||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||||
import org.thoughtcrime.securesms.backup.FullBackupBase;
|
import org.thoughtcrime.securesms.backup.BackupEvent;
|
||||||
import org.thoughtcrime.securesms.backup.FullBackupImporter;
|
import org.thoughtcrime.securesms.backup.FullBackupImporter;
|
||||||
import org.thoughtcrime.securesms.components.LabeledEditText;
|
import org.thoughtcrime.securesms.components.LabeledEditText;
|
||||||
import org.thoughtcrime.securesms.components.registration.CallMeCountDownView;
|
import org.thoughtcrime.securesms.components.registration.CallMeCountDownView;
|
||||||
@ -326,9 +327,9 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif
|
|||||||
Context context = RegistrationActivity.this;
|
Context context = RegistrationActivity.this;
|
||||||
SQLiteDatabase database = DatabaseFactory.getBackupDatabase(context);
|
SQLiteDatabase database = DatabaseFactory.getBackupDatabase(context);
|
||||||
|
|
||||||
FullBackupImporter.importFile(context,
|
FullBackupImporter.importFromUri(context,
|
||||||
AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
|
AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
|
||||||
database, backup.getFile(), passphrase);
|
database, Uri.fromFile(backup.getFile()), passphrase);
|
||||||
|
|
||||||
DatabaseFactory.upgradeRestored(context, database);
|
DatabaseFactory.upgradeRestored(context, database);
|
||||||
NotificationChannels.restoreContactNotificationChannels(context);
|
NotificationChannels.restoreContactNotificationChannels(context);
|
||||||
@ -871,7 +872,7 @@ public class RegistrationActivity extends BaseActionBarActivity implements Verif
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||||
public void onEvent(FullBackupBase.BackupEvent event) {
|
public void onEvent(BackupEvent event) {
|
||||||
if (event.getCount() == 0) restoreBackupProgress.setText(R.string.RegistrationActivity_checking);
|
if (event.getCount() == 0) restoreBackupProgress.setText(R.string.RegistrationActivity_checking);
|
||||||
else restoreBackupProgress.setText(getString(R.string.RegistrationActivity_d_messages_so_far, event.getCount()));
|
else restoreBackupProgress.setText(getString(R.string.RegistrationActivity_d_messages_so_far, event.getCount()));
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ public class BackupDialog {
|
|||||||
@NonNull BackupDirSelector backupDirSelector) {
|
@NonNull BackupDirSelector backupDirSelector) {
|
||||||
|
|
||||||
String[] password = BackupUtil.generateBackupPassphrase();
|
String[] password = BackupUtil.generateBackupPassphrase();
|
||||||
String passwordSt = Util.join(password, " ");
|
String passwordSt = Util.join(password, "");
|
||||||
|
|
||||||
AlertDialog dialog = new AlertDialog.Builder(context)
|
AlertDialog dialog = new AlertDialog.Builder(context)
|
||||||
.setTitle(R.string.BackupDialog_enable_local_backups)
|
.setTitle(R.string.BackupDialog_enable_local_backups)
|
||||||
@ -82,7 +82,7 @@ public class BackupDialog {
|
|||||||
|
|
||||||
dialog.findViewById(R.id.number_table).setOnClickListener(v -> {
|
dialog.findViewById(R.id.number_table).setOnClickListener(v -> {
|
||||||
((ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", passwordSt));
|
((ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", passwordSt));
|
||||||
Toast.makeText(context, R.string.BackupDialog_copied_to_clipboard, Toast.LENGTH_LONG).show();
|
Toast.makeText(context, R.string.BackupDialog_copied_to_clipboard, Toast.LENGTH_SHORT).show();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
14
src/org/thoughtcrime/securesms/backup/BackupEvent.kt
Normal file
14
src/org/thoughtcrime/securesms/backup/BackupEvent.kt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package org.thoughtcrime.securesms.backup
|
||||||
|
|
||||||
|
data class BackupEvent constructor(val type: Type, val count: Int, val exception: Exception?) {
|
||||||
|
|
||||||
|
enum class Type {
|
||||||
|
PROGRESS, FINISHED
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic fun createProgress(count: Int) = BackupEvent(Type.PROGRESS, count, null)
|
||||||
|
@JvmStatic fun createFinished() = BackupEvent(Type.FINISHED, 0, null)
|
||||||
|
@JvmStatic fun createFinished(e: Exception?) = BackupEvent(Type.FINISHED, 0, e)
|
||||||
|
}
|
||||||
|
}
|
@ -1,65 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.backup;
|
|
||||||
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
import org.greenrobot.eventbus.EventBus;
|
|
||||||
import org.whispersystems.libsignal.util.ByteUtil;
|
|
||||||
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
|
|
||||||
public abstract class FullBackupBase {
|
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
|
||||||
private static final String TAG = FullBackupBase.class.getSimpleName();
|
|
||||||
|
|
||||||
static class BackupStream {
|
|
||||||
static @NonNull byte[] getBackupKey(@NonNull String passphrase, @Nullable byte[] salt) {
|
|
||||||
try {
|
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0));
|
|
||||||
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-512");
|
|
||||||
byte[] input = passphrase.replace(" ", "").getBytes();
|
|
||||||
byte[] hash = input;
|
|
||||||
|
|
||||||
if (salt != null) digest.update(salt);
|
|
||||||
|
|
||||||
for (int i=0;i<250000;i++) {
|
|
||||||
if (i % 1000 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0));
|
|
||||||
digest.update(hash);
|
|
||||||
hash = digest.digest(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ByteUtil.trim(hash, 32);
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
throw new AssertionError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class BackupEvent {
|
|
||||||
public enum Type {
|
|
||||||
PROGRESS,
|
|
||||||
FINISHED
|
|
||||||
}
|
|
||||||
|
|
||||||
private final Type type;
|
|
||||||
private final int count;
|
|
||||||
|
|
||||||
BackupEvent(Type type, int count) {
|
|
||||||
this.type = type;
|
|
||||||
this.count = count;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Type getType() {
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getCount() {
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,429 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.backup;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.text.TextUtils;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
import com.annimon.stream.function.Consumer;
|
|
||||||
import com.annimon.stream.function.Predicate;
|
|
||||||
import com.google.protobuf.ByteString;
|
|
||||||
|
|
||||||
import net.sqlcipher.database.SQLiteDatabase;
|
|
||||||
|
|
||||||
import org.greenrobot.eventbus.EventBus;
|
|
||||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
|
||||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
|
||||||
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
|
|
||||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
|
|
||||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
|
|
||||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
|
||||||
import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.SearchDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.SessionDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.StickerDatabase;
|
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
|
||||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
|
||||||
import org.thoughtcrime.securesms.util.Conversions;
|
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
|
||||||
import org.whispersystems.libsignal.kdf.HKDFv3;
|
|
||||||
import org.whispersystems.libsignal.util.ByteUtil;
|
|
||||||
|
|
||||||
import java.io.Closeable;
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.Flushable;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.io.OutputStream;
|
|
||||||
import java.security.InvalidAlgorithmParameterException;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.LinkedList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.Cipher;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.Mac;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import javax.crypto.spec.IvParameterSpec;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
|
|
||||||
public class FullBackupExporter extends FullBackupBase {
|
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
|
||||||
private static final String TAG = FullBackupExporter.class.getSimpleName();
|
|
||||||
|
|
||||||
public static void export(@NonNull Context context,
|
|
||||||
@NonNull AttachmentSecret attachmentSecret,
|
|
||||||
@NonNull SQLiteDatabase input,
|
|
||||||
@NonNull Uri fileUri,
|
|
||||||
@NonNull String passphrase)
|
|
||||||
throws IOException
|
|
||||||
{
|
|
||||||
OutputStream baseOutputStream = context.getContentResolver().openOutputStream(fileUri);
|
|
||||||
if (baseOutputStream == null) {
|
|
||||||
throw new IOException("Cannot open an output stream for the file URI: " + fileUri.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
try (BackupFrameOutputStream outputStream = new BackupFrameOutputStream(baseOutputStream, passphrase)) {
|
|
||||||
outputStream.writeDatabaseVersion(input.getVersion());
|
|
||||||
|
|
||||||
List<String> tables = exportSchema(input, outputStream);
|
|
||||||
int count = 0;
|
|
||||||
|
|
||||||
for (String table : tables) {
|
|
||||||
if (table.equals(SmsDatabase.TABLE_NAME) || table.equals(MmsDatabase.TABLE_NAME)) {
|
|
||||||
count = exportTable(table, input, outputStream, cursor -> cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0, null, count);
|
|
||||||
} else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) {
|
|
||||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count);
|
|
||||||
} else if (table.equals(AttachmentDatabase.TABLE_NAME)) {
|
|
||||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), cursor -> exportAttachment(attachmentSecret, cursor, outputStream), count);
|
|
||||||
} else if (table.equals(StickerDatabase.TABLE_NAME)) {
|
|
||||||
count = exportTable(table, input, outputStream, cursor -> true, cursor -> exportSticker(attachmentSecret, cursor, outputStream), count);
|
|
||||||
} else if (!table.equals(SignedPreKeyDatabase.TABLE_NAME) &&
|
|
||||||
!table.equals(OneTimePreKeyDatabase.TABLE_NAME) &&
|
|
||||||
!table.equals(SessionDatabase.TABLE_NAME) &&
|
|
||||||
!table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) &&
|
|
||||||
!table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) &&
|
|
||||||
!table.startsWith("sqlite_"))
|
|
||||||
{
|
|
||||||
count = exportTable(table, input, outputStream, null, null, count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) {
|
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
|
|
||||||
outputStream.write(preference);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (File avatar : AvatarHelper.getAvatarFiles(context)) {
|
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
|
|
||||||
outputStream.write(avatar.getName(), new FileInputStream(avatar), avatar.length());
|
|
||||||
}
|
|
||||||
|
|
||||||
outputStream.writeEnd();
|
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<String> exportSchema(@NonNull SQLiteDatabase input, @NonNull BackupFrameOutputStream outputStream)
|
|
||||||
throws IOException
|
|
||||||
{
|
|
||||||
List<String> tables = new LinkedList<>();
|
|
||||||
|
|
||||||
try (Cursor cursor = input.rawQuery("SELECT sql, name, type FROM sqlite_master", null)) {
|
|
||||||
while (cursor != null && cursor.moveToNext()) {
|
|
||||||
String sql = cursor.getString(0);
|
|
||||||
String name = cursor.getString(1);
|
|
||||||
String type = cursor.getString(2);
|
|
||||||
|
|
||||||
if (sql != null) {
|
|
||||||
|
|
||||||
boolean isSmsFtsSecretTable = name != null && !name.equals(SearchDatabase.SMS_FTS_TABLE_NAME) && name.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME);
|
|
||||||
boolean isMmsFtsSecretTable = name != null && !name.equals(SearchDatabase.MMS_FTS_TABLE_NAME) && name.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME);
|
|
||||||
|
|
||||||
if (!isSmsFtsSecretTable && !isMmsFtsSecretTable) {
|
|
||||||
if ("table".equals(type)) {
|
|
||||||
tables.add(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
outputStream.write(BackupProtos.SqlStatement.newBuilder().setStatement(cursor.getString(0)).build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tables;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int exportTable(@NonNull String table,
|
|
||||||
@NonNull SQLiteDatabase input,
|
|
||||||
@NonNull BackupFrameOutputStream outputStream,
|
|
||||||
@Nullable Predicate<Cursor> predicate,
|
|
||||||
@Nullable Consumer<Cursor> postProcess,
|
|
||||||
int count)
|
|
||||||
throws IOException
|
|
||||||
{
|
|
||||||
String template = "INSERT INTO " + table + " VALUES ";
|
|
||||||
|
|
||||||
try (Cursor cursor = input.rawQuery("SELECT * FROM " + table, null)) {
|
|
||||||
while (cursor != null && cursor.moveToNext()) {
|
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
|
|
||||||
|
|
||||||
if (predicate == null || predicate.test(cursor)) {
|
|
||||||
StringBuilder statement = new StringBuilder(template);
|
|
||||||
BackupProtos.SqlStatement.Builder statementBuilder = BackupProtos.SqlStatement.newBuilder();
|
|
||||||
|
|
||||||
statement.append('(');
|
|
||||||
|
|
||||||
for (int i=0;i<cursor.getColumnCount();i++) {
|
|
||||||
statement.append('?');
|
|
||||||
|
|
||||||
if (cursor.getType(i) == Cursor.FIELD_TYPE_STRING) {
|
|
||||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setStringParamter(cursor.getString(i)));
|
|
||||||
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_FLOAT) {
|
|
||||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setDoubleParameter(cursor.getDouble(i)));
|
|
||||||
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_INTEGER) {
|
|
||||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setIntegerParameter(cursor.getLong(i)));
|
|
||||||
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_BLOB) {
|
|
||||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setBlobParameter(ByteString.copyFrom(cursor.getBlob(i))));
|
|
||||||
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_NULL) {
|
|
||||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setNullparameter(true));
|
|
||||||
} else {
|
|
||||||
throw new AssertionError("unknown type?" + cursor.getType(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (i < cursor.getColumnCount()-1) {
|
|
||||||
statement.append(',');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
statement.append(')');
|
|
||||||
|
|
||||||
outputStream.write(statementBuilder.setStatement(statement.toString()).build());
|
|
||||||
|
|
||||||
if (postProcess != null) postProcess.accept(cursor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void exportAttachment(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream) {
|
|
||||||
try {
|
|
||||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID));
|
|
||||||
long uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID));
|
|
||||||
long size = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.SIZE));
|
|
||||||
|
|
||||||
String data = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA));
|
|
||||||
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA_RANDOM));
|
|
||||||
|
|
||||||
if (!TextUtils.isEmpty(data) && size <= 0) {
|
|
||||||
size = calculateVeryOldStreamLength(attachmentSecret, random, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
|
||||||
InputStream inputStream;
|
|
||||||
|
|
||||||
if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
|
|
||||||
else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data));
|
|
||||||
|
|
||||||
outputStream.write(new AttachmentId(rowId, uniqueId), inputStream, size);
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.w(TAG, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void exportSticker(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream) {
|
|
||||||
try {
|
|
||||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase._ID));
|
|
||||||
long size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_LENGTH));
|
|
||||||
|
|
||||||
String data = cursor.getString(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_PATH));
|
|
||||||
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM));
|
|
||||||
|
|
||||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
|
||||||
try (InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0)) {
|
|
||||||
outputStream.writeSticker(rowId, inputStream, size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.w(TAG, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long calculateVeryOldStreamLength(@NonNull AttachmentSecret attachmentSecret, @Nullable byte[] random, @NonNull String data) throws IOException {
|
|
||||||
long result = 0;
|
|
||||||
InputStream inputStream;
|
|
||||||
|
|
||||||
if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
|
|
||||||
else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data));
|
|
||||||
|
|
||||||
int read;
|
|
||||||
byte[] buffer = new byte[8192];
|
|
||||||
|
|
||||||
while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) {
|
|
||||||
result += read;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, long mmsId) {
|
|
||||||
String[] columns = new String[] { MmsDatabase.EXPIRES_IN };
|
|
||||||
String where = MmsDatabase.ID + " = ?";
|
|
||||||
String[] args = new String[] { String.valueOf(mmsId) };
|
|
||||||
|
|
||||||
try (Cursor mmsCursor = db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) {
|
|
||||||
if (mmsCursor != null && mmsCursor.moveToFirst()) {
|
|
||||||
return mmsCursor.getLong(0) == 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static class BackupFrameOutputStream extends BackupStream implements Closeable, Flushable {
|
|
||||||
|
|
||||||
private final OutputStream outputStream;
|
|
||||||
private final Cipher cipher;
|
|
||||||
private final Mac mac;
|
|
||||||
|
|
||||||
private final byte[] cipherKey;
|
|
||||||
private final byte[] macKey;
|
|
||||||
|
|
||||||
private byte[] iv;
|
|
||||||
private int counter;
|
|
||||||
|
|
||||||
private BackupFrameOutputStream(@NonNull OutputStream outputStream, @NonNull String passphrase) throws IOException {
|
|
||||||
try {
|
|
||||||
byte[] salt = Util.getSecretBytes(32);
|
|
||||||
byte[] key = getBackupKey(passphrase, salt);
|
|
||||||
byte[] derived = new HKDFv3().deriveSecrets(key, "Backup Export".getBytes(), 64);
|
|
||||||
byte[][] split = ByteUtil.split(derived, 32, 32);
|
|
||||||
|
|
||||||
this.cipherKey = split[0];
|
|
||||||
this.macKey = split[1];
|
|
||||||
|
|
||||||
this.cipher = Cipher.getInstance("AES/CTR/NoPadding");
|
|
||||||
this.mac = Mac.getInstance("HmacSHA256");
|
|
||||||
this.outputStream = outputStream;
|
|
||||||
this.iv = Util.getSecretBytes(16);
|
|
||||||
this.counter = Conversions.byteArrayToInt(iv);
|
|
||||||
|
|
||||||
mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
|
|
||||||
|
|
||||||
byte[] header = BackupProtos.BackupFrame.newBuilder().setHeader(BackupProtos.Header.newBuilder()
|
|
||||||
.setIv(ByteString.copyFrom(iv))
|
|
||||||
.setSalt(ByteString.copyFrom(salt)))
|
|
||||||
.build().toByteArray();
|
|
||||||
|
|
||||||
outputStream.write(Conversions.intToByteArray(header.length));
|
|
||||||
outputStream.write(header);
|
|
||||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) {
|
|
||||||
throw new AssertionError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void write(BackupProtos.SharedPreference preference) throws IOException {
|
|
||||||
write(outputStream, BackupProtos.BackupFrame.newBuilder().setPreference(preference).build());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void write(BackupProtos.SqlStatement statement) throws IOException {
|
|
||||||
write(outputStream, BackupProtos.BackupFrame.newBuilder().setStatement(statement).build());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void write(@NonNull String avatarName, @NonNull InputStream in, long size) throws IOException {
|
|
||||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
|
||||||
.setAvatar(BackupProtos.Avatar.newBuilder()
|
|
||||||
.setName(avatarName)
|
|
||||||
.setLength(Util.toIntExact(size))
|
|
||||||
.build())
|
|
||||||
.build());
|
|
||||||
|
|
||||||
writeStream(in);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void write(@NonNull AttachmentId attachmentId, @NonNull InputStream in, long size) throws IOException {
|
|
||||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
|
||||||
.setAttachment(BackupProtos.Attachment.newBuilder()
|
|
||||||
.setRowId(attachmentId.getRowId())
|
|
||||||
.setAttachmentId(attachmentId.getUniqueId())
|
|
||||||
.setLength(Util.toIntExact(size))
|
|
||||||
.build())
|
|
||||||
.build());
|
|
||||||
|
|
||||||
writeStream(in);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void writeSticker(long rowId, @NonNull InputStream in, long size) throws IOException {
|
|
||||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
|
||||||
.setSticker(BackupProtos.Sticker.newBuilder()
|
|
||||||
.setRowId(rowId)
|
|
||||||
.setLength(Util.toIntExact(size))
|
|
||||||
.build())
|
|
||||||
.build());
|
|
||||||
|
|
||||||
writeStream(in);
|
|
||||||
}
|
|
||||||
|
|
||||||
void writeDatabaseVersion(int version) throws IOException {
|
|
||||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
|
||||||
.setVersion(BackupProtos.DatabaseVersion.newBuilder().setVersion(version))
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
void writeEnd() throws IOException {
|
|
||||||
write(outputStream, BackupProtos.BackupFrame.newBuilder().setEnd(true).build());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeStream(@NonNull InputStream inputStream) throws IOException {
|
|
||||||
try {
|
|
||||||
Conversions.intToByteArray(iv, 0, counter++);
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
|
||||||
mac.update(iv);
|
|
||||||
|
|
||||||
byte[] buffer = new byte[8192];
|
|
||||||
int read;
|
|
||||||
|
|
||||||
while ((read = inputStream.read(buffer)) != -1) {
|
|
||||||
byte[] ciphertext = cipher.update(buffer, 0, read);
|
|
||||||
|
|
||||||
if (ciphertext != null) {
|
|
||||||
outputStream.write(ciphertext);
|
|
||||||
mac.update(ciphertext);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] remainder = cipher.doFinal();
|
|
||||||
outputStream.write(remainder);
|
|
||||||
mac.update(remainder);
|
|
||||||
|
|
||||||
byte[] attachmentDigest = mac.doFinal();
|
|
||||||
outputStream.write(attachmentDigest, 0, 10);
|
|
||||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
|
||||||
throw new AssertionError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void write(@NonNull OutputStream out, @NonNull BackupProtos.BackupFrame frame) throws IOException {
|
|
||||||
try {
|
|
||||||
Conversions.intToByteArray(iv, 0, counter++);
|
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
|
||||||
|
|
||||||
byte[] frameCiphertext = cipher.doFinal(frame.toByteArray());
|
|
||||||
byte[] frameMac = mac.doFinal(frameCiphertext);
|
|
||||||
byte[] length = Conversions.intToByteArray(frameCiphertext.length + 10);
|
|
||||||
|
|
||||||
out.write(length);
|
|
||||||
out.write(frameCiphertext);
|
|
||||||
out.write(frameMac, 0, 10);
|
|
||||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
|
||||||
throw new AssertionError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void flush() throws IOException {
|
|
||||||
outputStream.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() throws IOException {
|
|
||||||
outputStream.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
449
src/org/thoughtcrime/securesms/backup/FullBackupExporter.kt
Normal file
449
src/org/thoughtcrime/securesms/backup/FullBackupExporter.kt
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
package org.thoughtcrime.securesms.backup
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.text.TextUtils
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import com.annimon.stream.function.Consumer
|
||||||
|
import com.annimon.stream.function.Predicate
|
||||||
|
import com.google.protobuf.ByteString
|
||||||
|
import net.sqlcipher.database.SQLiteDatabase
|
||||||
|
import org.greenrobot.eventbus.EventBus
|
||||||
|
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||||
|
import org.thoughtcrime.securesms.backup.BackupProtos.*
|
||||||
|
import org.thoughtcrime.securesms.crypto.AttachmentSecret
|
||||||
|
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream
|
||||||
|
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||||
|
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream
|
||||||
|
import org.thoughtcrime.securesms.database.*
|
||||||
|
import org.thoughtcrime.securesms.logging.Log
|
||||||
|
import org.thoughtcrime.securesms.loki.database.LokiAPIDatabase
|
||||||
|
import org.thoughtcrime.securesms.loki.database.LokiBackupFilesDatabase
|
||||||
|
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||||
|
import org.thoughtcrime.securesms.util.BackupUtil
|
||||||
|
import org.thoughtcrime.securesms.util.Conversions
|
||||||
|
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||||
|
import org.thoughtcrime.securesms.util.Util
|
||||||
|
import org.whispersystems.libsignal.kdf.HKDFv3
|
||||||
|
import org.whispersystems.libsignal.util.ByteUtil
|
||||||
|
import java.io.*
|
||||||
|
import java.lang.Exception
|
||||||
|
import java.security.InvalidAlgorithmParameterException
|
||||||
|
import java.security.InvalidKeyException
|
||||||
|
import java.security.NoSuchAlgorithmException
|
||||||
|
import java.util.*
|
||||||
|
import javax.crypto.*
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
object FullBackupExporter {
|
||||||
|
private val TAG = FullBackupExporter::class.java.simpleName
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@WorkerThread
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun export(context: Context,
|
||||||
|
attachmentSecret: AttachmentSecret,
|
||||||
|
input: SQLiteDatabase,
|
||||||
|
fileUri: Uri,
|
||||||
|
passphrase: String) {
|
||||||
|
|
||||||
|
val baseOutputStream = context.contentResolver.openOutputStream(fileUri)
|
||||||
|
?: throw IOException("Cannot open an output stream for the file URI: $fileUri")
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
try {
|
||||||
|
BackupFrameOutputStream(baseOutputStream, passphrase).use { outputStream ->
|
||||||
|
outputStream.writeDatabaseVersion(input.version)
|
||||||
|
val tables = exportSchema(input, outputStream)
|
||||||
|
for (table in tables) if (shouldExportTable(table)) {
|
||||||
|
count = when (table) {
|
||||||
|
SmsDatabase.TABLE_NAME, MmsDatabase.TABLE_NAME -> {
|
||||||
|
exportTable(table, input, outputStream,
|
||||||
|
{ cursor: Cursor ->
|
||||||
|
cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
count)
|
||||||
|
}
|
||||||
|
GroupReceiptDatabase.TABLE_NAME -> {
|
||||||
|
exportTable(table, input, outputStream,
|
||||||
|
{ cursor: Cursor ->
|
||||||
|
isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID)))
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
count)
|
||||||
|
}
|
||||||
|
AttachmentDatabase.TABLE_NAME -> {
|
||||||
|
exportTable(table, input, outputStream,
|
||||||
|
{ cursor: Cursor ->
|
||||||
|
isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID)))
|
||||||
|
},
|
||||||
|
{ cursor: Cursor ->
|
||||||
|
exportAttachment(attachmentSecret, cursor, outputStream)
|
||||||
|
},
|
||||||
|
count)
|
||||||
|
}
|
||||||
|
StickerDatabase.TABLE_NAME -> {
|
||||||
|
exportTable(table, input, outputStream,
|
||||||
|
{ true },
|
||||||
|
{ cursor: Cursor -> exportSticker(attachmentSecret, cursor, outputStream) },
|
||||||
|
count)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
exportTable(table, input, outputStream, null, null, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (preference in IdentityKeyUtil.getBackupRecords(context)) {
|
||||||
|
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||||
|
outputStream.writePreferenceEntry(preference)
|
||||||
|
}
|
||||||
|
for (preference in TextSecurePreferences.getBackupRecords(context)) {
|
||||||
|
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||||
|
outputStream.writePreferenceEntry(preference)
|
||||||
|
}
|
||||||
|
for (avatar in AvatarHelper.getAvatarFiles(context)) {
|
||||||
|
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||||
|
outputStream.writeAvatar(avatar.name, FileInputStream(avatar), avatar.length())
|
||||||
|
}
|
||||||
|
outputStream.writeEnd()
|
||||||
|
}
|
||||||
|
EventBus.getDefault().post(BackupEvent.createFinished())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to make full backup.", e)
|
||||||
|
EventBus.getDefault().post(BackupEvent.createFinished(e))
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun shouldExportTable(table: String): Boolean {
|
||||||
|
return table != SignedPreKeyDatabase.TABLE_NAME &&
|
||||||
|
table != OneTimePreKeyDatabase.TABLE_NAME &&
|
||||||
|
table != SessionDatabase.TABLE_NAME &&
|
||||||
|
table != PushDatabase.TABLE_NAME &&
|
||||||
|
|
||||||
|
table != LokiBackupFilesDatabase.TABLE_NAME &&
|
||||||
|
table != LokiAPIDatabase.openGroupProfilePictureTable &&
|
||||||
|
|
||||||
|
table != JobDatabase.Jobs.TABLE_NAME &&
|
||||||
|
table != JobDatabase.Constraints.TABLE_NAME &&
|
||||||
|
table != JobDatabase.Dependencies.TABLE_NAME &&
|
||||||
|
|
||||||
|
!table.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME) &&
|
||||||
|
!table.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME) &&
|
||||||
|
!table.startsWith("sqlite_")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun exportSchema(input: SQLiteDatabase, outputStream: BackupFrameOutputStream): List<String> {
|
||||||
|
val tables: MutableList<String> = LinkedList()
|
||||||
|
input.rawQuery("SELECT sql, name, type FROM sqlite_master", null).use { cursor ->
|
||||||
|
while (cursor != null && cursor.moveToNext()) {
|
||||||
|
val sql = cursor.getString(0)
|
||||||
|
val name = cursor.getString(1)
|
||||||
|
val type = cursor.getString(2)
|
||||||
|
if (sql != null) {
|
||||||
|
val isSmsFtsSecretTable = name != null && name != SearchDatabase.SMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.SMS_FTS_TABLE_NAME)
|
||||||
|
val isMmsFtsSecretTable = name != null && name != SearchDatabase.MMS_FTS_TABLE_NAME && name.startsWith(SearchDatabase.MMS_FTS_TABLE_NAME)
|
||||||
|
if (!isSmsFtsSecretTable && !isMmsFtsSecretTable) {
|
||||||
|
if ("table" == type) {
|
||||||
|
tables.add(name)
|
||||||
|
}
|
||||||
|
outputStream.writeSql(SqlStatement.newBuilder().setStatement(cursor.getString(0)).build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tables
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun exportTable(table: String,
|
||||||
|
input: SQLiteDatabase,
|
||||||
|
outputStream: BackupFrameOutputStream,
|
||||||
|
predicate: Predicate<Cursor>?,
|
||||||
|
postProcess: Consumer<Cursor>?,
|
||||||
|
count: Int): Int {
|
||||||
|
var count = count
|
||||||
|
val template = "INSERT INTO $table VALUES "
|
||||||
|
input.rawQuery("SELECT * FROM $table", null).use { cursor ->
|
||||||
|
while (cursor != null && cursor.moveToNext()) {
|
||||||
|
EventBus.getDefault().post(BackupEvent.createProgress(++count))
|
||||||
|
if (predicate != null && !predicate.test(cursor)) continue
|
||||||
|
|
||||||
|
val statement = StringBuilder(template)
|
||||||
|
val statementBuilder = SqlStatement.newBuilder()
|
||||||
|
statement.append('(')
|
||||||
|
for (i in 0 until cursor.columnCount) {
|
||||||
|
statement.append('?')
|
||||||
|
when (cursor.getType(i)) {
|
||||||
|
Cursor.FIELD_TYPE_STRING -> {
|
||||||
|
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||||
|
.setStringParamter(cursor.getString(i)))
|
||||||
|
}
|
||||||
|
Cursor.FIELD_TYPE_FLOAT -> {
|
||||||
|
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||||
|
.setDoubleParameter(cursor.getDouble(i)))
|
||||||
|
}
|
||||||
|
Cursor.FIELD_TYPE_INTEGER -> {
|
||||||
|
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||||
|
.setIntegerParameter(cursor.getLong(i)))
|
||||||
|
}
|
||||||
|
Cursor.FIELD_TYPE_BLOB -> {
|
||||||
|
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||||
|
.setBlobParameter(ByteString.copyFrom(cursor.getBlob(i))))
|
||||||
|
}
|
||||||
|
Cursor.FIELD_TYPE_NULL -> {
|
||||||
|
statementBuilder.addParameters(SqlStatement.SqlParameter.newBuilder()
|
||||||
|
.setNullparameter(true))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
throw AssertionError("unknown type?" + cursor.getType(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i < cursor.columnCount - 1) {
|
||||||
|
statement.append(',')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statement.append(')')
|
||||||
|
outputStream.writeSql(statementBuilder.setStatement(statement.toString()).build())
|
||||||
|
postProcess?.accept(cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exportAttachment(attachmentSecret: AttachmentSecret, cursor: Cursor, outputStream: BackupFrameOutputStream) {
|
||||||
|
try {
|
||||||
|
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID))
|
||||||
|
val uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID))
|
||||||
|
var size = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.SIZE))
|
||||||
|
val data = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA))
|
||||||
|
val random = cursor.getBlob(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA_RANDOM))
|
||||||
|
if (!TextUtils.isEmpty(data) && size <= 0) {
|
||||||
|
size = calculateVeryOldStreamLength(attachmentSecret, random, data)
|
||||||
|
}
|
||||||
|
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||||
|
val inputStream: InputStream = if (random != null && random.size == 32) {
|
||||||
|
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0)
|
||||||
|
} else {
|
||||||
|
ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data))
|
||||||
|
}
|
||||||
|
outputStream.writeAttachment(AttachmentId(rowId, uniqueId), inputStream, size)
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.w(TAG, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exportSticker(attachmentSecret: AttachmentSecret, cursor: Cursor, outputStream: BackupFrameOutputStream) {
|
||||||
|
try {
|
||||||
|
val rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase._ID))
|
||||||
|
val size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_LENGTH))
|
||||||
|
val data = cursor.getString(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_PATH))
|
||||||
|
val random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM))
|
||||||
|
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||||
|
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0).use { inputStream -> outputStream.writeSticker(rowId, inputStream, size) }
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.w(TAG, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun calculateVeryOldStreamLength(attachmentSecret: AttachmentSecret, random: ByteArray?, data: String): Long {
|
||||||
|
var result: Long = 0
|
||||||
|
val inputStream: InputStream = if (random != null && random.size == 32) {
|
||||||
|
ModernDecryptingPartInputStream.createFor(attachmentSecret, random, File(data), 0)
|
||||||
|
} else {
|
||||||
|
ClassicDecryptingPartInputStream.createFor(attachmentSecret, File(data))
|
||||||
|
}
|
||||||
|
var read: Int
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
while (inputStream.read(buffer, 0, buffer.size).also { read = it } != -1) {
|
||||||
|
result += read.toLong()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isForNonExpiringMessage(db: SQLiteDatabase, mmsId: Long): Boolean {
|
||||||
|
val columns = arrayOf(MmsDatabase.EXPIRES_IN)
|
||||||
|
val where = MmsDatabase.ID + " = ?"
|
||||||
|
val args = arrayOf(mmsId.toString())
|
||||||
|
db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null).use { mmsCursor ->
|
||||||
|
if (mmsCursor != null && mmsCursor.moveToFirst()) {
|
||||||
|
return mmsCursor.getLong(0) == 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BackupFrameOutputStream : Closeable, Flushable {
|
||||||
|
|
||||||
|
private val outputStream: OutputStream
|
||||||
|
private var cipher: Cipher
|
||||||
|
private var mac: Mac
|
||||||
|
private val cipherKey: ByteArray
|
||||||
|
private val macKey: ByteArray
|
||||||
|
private val iv: ByteArray
|
||||||
|
|
||||||
|
private var counter: Int = 0
|
||||||
|
|
||||||
|
constructor(outputStream: OutputStream, passphrase: String) : super() {
|
||||||
|
try {
|
||||||
|
val salt = Util.getSecretBytes(32)
|
||||||
|
val key = BackupUtil.computeBackupKey(passphrase, salt)
|
||||||
|
val derived = HKDFv3().deriveSecrets(key, "Backup Export".toByteArray(), 64)
|
||||||
|
val split = ByteUtil.split(derived, 32, 32)
|
||||||
|
cipherKey = split[0]
|
||||||
|
macKey = split[1]
|
||||||
|
cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
||||||
|
mac = Mac.getInstance("HmacSHA256")
|
||||||
|
this.outputStream = outputStream
|
||||||
|
iv = Util.getSecretBytes(16)
|
||||||
|
counter = Conversions.byteArrayToInt(iv)
|
||||||
|
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
|
||||||
|
val header = BackupFrame.newBuilder().setHeader(Header.newBuilder()
|
||||||
|
.setIv(ByteString.copyFrom(iv))
|
||||||
|
.setSalt(ByteString.copyFrom(salt)))
|
||||||
|
.build().toByteArray()
|
||||||
|
outputStream.write(Conversions.intToByteArray(header.size))
|
||||||
|
outputStream.write(header)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
when (e) {
|
||||||
|
is NoSuchAlgorithmException,
|
||||||
|
is NoSuchPaddingException,
|
||||||
|
is InvalidKeyException -> {
|
||||||
|
throw AssertionError(e)
|
||||||
|
}
|
||||||
|
else -> throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun writeSql(statement: SqlStatement) {
|
||||||
|
write(outputStream, BackupFrame.newBuilder().setStatement(statement).build())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun writePreferenceEntry(preference: SharedPreference?) {
|
||||||
|
write(outputStream, BackupFrame.newBuilder().setPreference(preference).build())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun writeAvatar(avatarName: String, inputStream: InputStream, size: Long) {
|
||||||
|
write(outputStream, BackupFrame.newBuilder()
|
||||||
|
.setAvatar(Avatar.newBuilder()
|
||||||
|
.setName(avatarName)
|
||||||
|
.setLength(Util.toIntExact(size))
|
||||||
|
.build())
|
||||||
|
.build())
|
||||||
|
writeStream(inputStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun writeAttachment(attachmentId: AttachmentId, inputStream: InputStream, size: Long) {
|
||||||
|
write(outputStream, BackupFrame.newBuilder()
|
||||||
|
.setAttachment(Attachment.newBuilder()
|
||||||
|
.setRowId(attachmentId.rowId)
|
||||||
|
.setAttachmentId(attachmentId.uniqueId)
|
||||||
|
.setLength(Util.toIntExact(size))
|
||||||
|
.build())
|
||||||
|
.build())
|
||||||
|
writeStream(inputStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun writeSticker(rowId: Long, inputStream: InputStream, size: Long) {
|
||||||
|
write(outputStream, BackupFrame.newBuilder()
|
||||||
|
.setSticker(Sticker.newBuilder()
|
||||||
|
.setRowId(rowId)
|
||||||
|
.setLength(Util.toIntExact(size))
|
||||||
|
.build())
|
||||||
|
.build())
|
||||||
|
writeStream(inputStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun writeDatabaseVersion(version: Int) {
|
||||||
|
write(outputStream, BackupFrame.newBuilder()
|
||||||
|
.setVersion(DatabaseVersion.newBuilder().setVersion(version))
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun writeEnd() {
|
||||||
|
write(outputStream, BackupFrame.newBuilder().setEnd(true).build())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun writeStream(inputStream: InputStream) {
|
||||||
|
try {
|
||||||
|
Conversions.intToByteArray(iv, 0, counter++)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||||
|
mac.update(iv)
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
var read: Int
|
||||||
|
while (inputStream.read(buffer).also { read = it } != -1) {
|
||||||
|
val ciphertext = cipher.update(buffer, 0, read)
|
||||||
|
if (ciphertext != null) {
|
||||||
|
outputStream.write(ciphertext)
|
||||||
|
mac.update(ciphertext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val remainder = cipher.doFinal()
|
||||||
|
outputStream.write(remainder)
|
||||||
|
mac.update(remainder)
|
||||||
|
val attachmentDigest = mac.doFinal()
|
||||||
|
outputStream.write(attachmentDigest, 0, 10)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
when (e) {
|
||||||
|
is InvalidKeyException,
|
||||||
|
is InvalidAlgorithmParameterException,
|
||||||
|
is IllegalBlockSizeException,
|
||||||
|
is BadPaddingException -> {
|
||||||
|
throw AssertionError(e)
|
||||||
|
}
|
||||||
|
else -> throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun write(out: OutputStream, frame: BackupFrame) {
|
||||||
|
try {
|
||||||
|
Conversions.intToByteArray(iv, 0, counter++)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||||
|
val frameCiphertext = cipher.doFinal(frame.toByteArray())
|
||||||
|
val frameMac = mac.doFinal(frameCiphertext)
|
||||||
|
val length = Conversions.intToByteArray(frameCiphertext.size + 10)
|
||||||
|
out.write(length)
|
||||||
|
out.write(frameCiphertext)
|
||||||
|
out.write(frameMac, 0, 10)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
when (e) {
|
||||||
|
is InvalidKeyException,
|
||||||
|
is InvalidAlgorithmParameterException,
|
||||||
|
is IllegalBlockSizeException,
|
||||||
|
is BadPaddingException -> {
|
||||||
|
throw AssertionError(e)
|
||||||
|
}
|
||||||
|
else -> throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun flush() {
|
||||||
|
outputStream.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun close() {
|
||||||
|
outputStream.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,359 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.backup;
|
|
||||||
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
|
||||||
import android.content.ContentValues;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.database.Cursor;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import android.util.Pair;
|
|
||||||
|
|
||||||
import net.sqlcipher.database.SQLiteDatabase;
|
|
||||||
|
|
||||||
import org.greenrobot.eventbus.EventBus;
|
|
||||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment;
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame;
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion;
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference;
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement;
|
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.Sticker;
|
|
||||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
|
||||||
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
|
|
||||||
import org.thoughtcrime.securesms.database.Address;
|
|
||||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
|
||||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.SearchDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.StickerDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
|
||||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
|
||||||
import org.thoughtcrime.securesms.util.Conversions;
|
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
|
||||||
import org.whispersystems.libsignal.kdf.HKDFv3;
|
|
||||||
import org.whispersystems.libsignal.util.ByteUtil;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.FileOutputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.io.OutputStream;
|
|
||||||
import java.security.InvalidAlgorithmParameterException;
|
|
||||||
import java.security.InvalidKeyException;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.util.LinkedList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import javax.crypto.BadPaddingException;
|
|
||||||
import javax.crypto.Cipher;
|
|
||||||
import javax.crypto.IllegalBlockSizeException;
|
|
||||||
import javax.crypto.Mac;
|
|
||||||
import javax.crypto.NoSuchPaddingException;
|
|
||||||
import javax.crypto.spec.IvParameterSpec;
|
|
||||||
import javax.crypto.spec.SecretKeySpec;
|
|
||||||
|
|
||||||
public class FullBackupImporter extends FullBackupBase {
|
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
|
||||||
private static final String TAG = FullBackupImporter.class.getSimpleName();
|
|
||||||
|
|
||||||
public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret,
|
|
||||||
@NonNull SQLiteDatabase db, @NonNull File file, @NonNull String passphrase)
|
|
||||||
throws IOException
|
|
||||||
{
|
|
||||||
BackupRecordInputStream inputStream = new BackupRecordInputStream(file, passphrase);
|
|
||||||
int count = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
db.beginTransaction();
|
|
||||||
|
|
||||||
dropAllTables(db);
|
|
||||||
|
|
||||||
BackupFrame frame;
|
|
||||||
|
|
||||||
while (!(frame = inputStream.readFrame()).getEnd()) {
|
|
||||||
if (count++ % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count));
|
|
||||||
|
|
||||||
if (frame.hasVersion()) processVersion(db, frame.getVersion());
|
|
||||||
else if (frame.hasStatement()) processStatement(db, frame.getStatement());
|
|
||||||
else if (frame.hasPreference()) processPreference(context, frame.getPreference());
|
|
||||||
else if (frame.hasAttachment()) processAttachment(context, attachmentSecret, db, frame.getAttachment(), inputStream);
|
|
||||||
else if (frame.hasSticker()) processSticker(context, attachmentSecret, db, frame.getSticker(), inputStream);
|
|
||||||
else if (frame.hasAvatar()) processAvatar(context, frame.getAvatar(), inputStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
trimEntriesForExpiredMessages(context, db);
|
|
||||||
|
|
||||||
db.setTransactionSuccessful();
|
|
||||||
} finally {
|
|
||||||
db.endTransaction();
|
|
||||||
}
|
|
||||||
|
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void processVersion(@NonNull SQLiteDatabase db, DatabaseVersion version) throws IOException {
|
|
||||||
if (version.getVersion() > db.getVersion()) {
|
|
||||||
throw new DatabaseDowngradeException(db.getVersion(), version.getVersion());
|
|
||||||
}
|
|
||||||
|
|
||||||
db.setVersion(version.getVersion());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void processStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
|
|
||||||
boolean isForSmsFtsSecretTable = statement.getStatement().contains(SearchDatabase.SMS_FTS_TABLE_NAME + "_");
|
|
||||||
boolean isForMmsFtsSecretTable = statement.getStatement().contains(SearchDatabase.MMS_FTS_TABLE_NAME + "_");
|
|
||||||
boolean isForSqliteSecretTable = statement.getStatement().toLowerCase().startsWith("create table sqlite_");
|
|
||||||
|
|
||||||
if (isForSmsFtsSecretTable || isForMmsFtsSecretTable || isForSqliteSecretTable) {
|
|
||||||
Log.i(TAG, "Ignoring import for statement: " + statement.getStatement());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Object> parameters = new LinkedList<>();
|
|
||||||
|
|
||||||
for (SqlStatement.SqlParameter parameter : statement.getParametersList()) {
|
|
||||||
if (parameter.hasStringParamter()) parameters.add(parameter.getStringParamter());
|
|
||||||
else if (parameter.hasDoubleParameter()) parameters.add(parameter.getDoubleParameter());
|
|
||||||
else if (parameter.hasIntegerParameter()) parameters.add(parameter.getIntegerParameter());
|
|
||||||
else if (parameter.hasBlobParameter()) parameters.add(parameter.getBlobParameter().toByteArray());
|
|
||||||
else if (parameter.hasNullparameter()) parameters.add(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parameters.size() > 0) db.execSQL(statement.getStatement(), parameters.toArray());
|
|
||||||
else db.execSQL(statement.getStatement());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream)
|
|
||||||
throws IOException
|
|
||||||
{
|
|
||||||
File partsDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE);
|
|
||||||
File dataFile = File.createTempFile("part", ".mms", partsDirectory);
|
|
||||||
|
|
||||||
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
|
|
||||||
|
|
||||||
inputStream.readAttachmentTo(output.second, attachment.getLength());
|
|
||||||
|
|
||||||
ContentValues contentValues = new ContentValues();
|
|
||||||
contentValues.put(AttachmentDatabase.DATA, dataFile.getAbsolutePath());
|
|
||||||
contentValues.put(AttachmentDatabase.THUMBNAIL, (String)null);
|
|
||||||
contentValues.put(AttachmentDatabase.DATA_RANDOM, output.first);
|
|
||||||
|
|
||||||
db.update(AttachmentDatabase.TABLE_NAME, contentValues,
|
|
||||||
AttachmentDatabase.ROW_ID + " = ? AND " + AttachmentDatabase.UNIQUE_ID + " = ?",
|
|
||||||
new String[] {String.valueOf(attachment.getRowId()), String.valueOf(attachment.getAttachmentId())});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void processSticker(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Sticker sticker, BackupRecordInputStream inputStream)
|
|
||||||
throws IOException
|
|
||||||
{
|
|
||||||
File stickerDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE);
|
|
||||||
File dataFile = File.createTempFile("sticker", ".mms", stickerDirectory);
|
|
||||||
|
|
||||||
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
|
|
||||||
|
|
||||||
inputStream.readAttachmentTo(output.second, sticker.getLength());
|
|
||||||
|
|
||||||
ContentValues contentValues = new ContentValues();
|
|
||||||
contentValues.put(StickerDatabase.FILE_PATH, dataFile.getAbsolutePath());
|
|
||||||
contentValues.put(StickerDatabase.FILE_RANDOM, output.first);
|
|
||||||
|
|
||||||
db.update(StickerDatabase.TABLE_NAME, contentValues,
|
|
||||||
StickerDatabase._ID + " = ?",
|
|
||||||
new String[] {String.valueOf(sticker.getRowId())});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void processAvatar(@NonNull Context context, @NonNull BackupProtos.Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException {
|
|
||||||
inputStream.readAttachmentTo(new FileOutputStream(AvatarHelper.getAvatarFile(context, Address.fromExternal(context, avatar.getName()))), avatar.getLength());
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("ApplySharedPref")
|
|
||||||
private static void processPreference(@NonNull Context context, SharedPreference preference) {
|
|
||||||
SharedPreferences preferences = context.getSharedPreferences(preference.getFile(), 0);
|
|
||||||
preferences.edit().putString(preference.getKey(), preference.getValue()).commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void dropAllTables(@NonNull SQLiteDatabase db) {
|
|
||||||
try (Cursor cursor = db.rawQuery("SELECT name, type FROM sqlite_master", null)) {
|
|
||||||
while (cursor != null && cursor.moveToNext()) {
|
|
||||||
String name = cursor.getString(0);
|
|
||||||
String type = cursor.getString(1);
|
|
||||||
|
|
||||||
if ("table".equals(type) && !name.startsWith("sqlite_")) {
|
|
||||||
db.execSQL("DROP TABLE IF EXISTS " + name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void trimEntriesForExpiredMessages(@NonNull Context context, @NonNull SQLiteDatabase db) {
|
|
||||||
String trimmedCondition = " NOT IN (SELECT " + MmsDatabase.ID + " FROM " + MmsDatabase.TABLE_NAME + ")";
|
|
||||||
|
|
||||||
db.delete(GroupReceiptDatabase.TABLE_NAME, GroupReceiptDatabase.MMS_ID + trimmedCondition, null);
|
|
||||||
|
|
||||||
String[] columns = new String[] { AttachmentDatabase.ROW_ID, AttachmentDatabase.UNIQUE_ID };
|
|
||||||
String where = AttachmentDatabase.MMS_ID + trimmedCondition;
|
|
||||||
|
|
||||||
try (Cursor cursor = db.query(AttachmentDatabase.TABLE_NAME, columns, where, null, null, null, null)) {
|
|
||||||
while (cursor != null && cursor.moveToNext()) {
|
|
||||||
DatabaseFactory.getAttachmentDatabase(context).deleteAttachment(new AttachmentId(cursor.getLong(0), cursor.getLong(1)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try (Cursor cursor = db.query(ThreadDatabase.TABLE_NAME, new String[] { ThreadDatabase.ID }, ThreadDatabase.EXPIRES_IN + " > 0", null, null, null, null)) {
|
|
||||||
while (cursor != null && cursor.moveToNext()) {
|
|
||||||
DatabaseFactory.getThreadDatabase(context).update(cursor.getLong(0), false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private static class BackupRecordInputStream extends BackupStream {
|
|
||||||
|
|
||||||
private final InputStream in;
|
|
||||||
private final Cipher cipher;
|
|
||||||
private final Mac mac;
|
|
||||||
|
|
||||||
private final byte[] cipherKey;
|
|
||||||
private final byte[] macKey;
|
|
||||||
|
|
||||||
private byte[] iv;
|
|
||||||
private int counter;
|
|
||||||
|
|
||||||
private BackupRecordInputStream(@NonNull File file, @NonNull String passphrase) throws IOException {
|
|
||||||
try {
|
|
||||||
this.in = new FileInputStream(file);
|
|
||||||
|
|
||||||
byte[] headerLengthBytes = new byte[4];
|
|
||||||
Util.readFully(in, headerLengthBytes);
|
|
||||||
|
|
||||||
int headerLength = Conversions.byteArrayToInt(headerLengthBytes);
|
|
||||||
byte[] headerFrame = new byte[headerLength];
|
|
||||||
Util.readFully(in, headerFrame);
|
|
||||||
|
|
||||||
BackupFrame frame = BackupFrame.parseFrom(headerFrame);
|
|
||||||
|
|
||||||
if (!frame.hasHeader()) {
|
|
||||||
throw new IOException("Backup stream does not start with header!");
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupProtos.Header header = frame.getHeader();
|
|
||||||
|
|
||||||
this.iv = header.getIv().toByteArray();
|
|
||||||
|
|
||||||
if (iv.length != 16) {
|
|
||||||
throw new IOException("Invalid IV length!");
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null);
|
|
||||||
byte[] derived = new HKDFv3().deriveSecrets(key, "Backup Export".getBytes(), 64);
|
|
||||||
byte[][] split = ByteUtil.split(derived, 32, 32);
|
|
||||||
|
|
||||||
this.cipherKey = split[0];
|
|
||||||
this.macKey = split[1];
|
|
||||||
|
|
||||||
this.cipher = Cipher.getInstance("AES/CTR/NoPadding");
|
|
||||||
this.mac = Mac.getInstance("HmacSHA256");
|
|
||||||
this.mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
|
|
||||||
|
|
||||||
this.counter = Conversions.byteArrayToInt(iv);
|
|
||||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) {
|
|
||||||
throw new AssertionError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupFrame readFrame() throws IOException {
|
|
||||||
return readFrame(in);
|
|
||||||
}
|
|
||||||
|
|
||||||
void readAttachmentTo(OutputStream out, int length) throws IOException {
|
|
||||||
try {
|
|
||||||
Conversions.intToByteArray(iv, 0, counter++);
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
|
||||||
mac.update(iv);
|
|
||||||
|
|
||||||
byte[] buffer = new byte[8192];
|
|
||||||
|
|
||||||
while (length > 0) {
|
|
||||||
int read = in.read(buffer, 0, Math.min(buffer.length, length));
|
|
||||||
if (read == -1) throw new IOException("File ended early!");
|
|
||||||
|
|
||||||
mac.update(buffer, 0, read);
|
|
||||||
|
|
||||||
byte[] plaintext = cipher.update(buffer, 0, read);
|
|
||||||
|
|
||||||
if (plaintext != null) {
|
|
||||||
out.write(plaintext, 0, plaintext.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
length -= read;
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] plaintext = cipher.doFinal();
|
|
||||||
|
|
||||||
if (plaintext != null) {
|
|
||||||
out.write(plaintext, 0, plaintext.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
out.close();
|
|
||||||
|
|
||||||
byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10);
|
|
||||||
byte[] theirMac = new byte[10];
|
|
||||||
|
|
||||||
try {
|
|
||||||
Util.readFully(in, theirMac);
|
|
||||||
} catch (IOException e) {
|
|
||||||
//destination.delete();
|
|
||||||
throw new IOException(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
|
||||||
//destination.delete();
|
|
||||||
throw new IOException("Bad MAC");
|
|
||||||
}
|
|
||||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
|
||||||
throw new AssertionError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private BackupFrame readFrame(InputStream in) throws IOException {
|
|
||||||
try {
|
|
||||||
byte[] length = new byte[4];
|
|
||||||
Util.readFully(in, length);
|
|
||||||
|
|
||||||
byte[] frame = new byte[Conversions.byteArrayToInt(length)];
|
|
||||||
Util.readFully(in, frame);
|
|
||||||
|
|
||||||
byte[] theirMac = new byte[10];
|
|
||||||
System.arraycopy(frame, frame.length - 10, theirMac, 0, theirMac.length);
|
|
||||||
|
|
||||||
mac.update(frame, 0, frame.length - 10);
|
|
||||||
byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10);
|
|
||||||
|
|
||||||
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
|
||||||
throw new IOException("Bad MAC");
|
|
||||||
}
|
|
||||||
|
|
||||||
Conversions.intToByteArray(iv, 0, counter++);
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
|
||||||
|
|
||||||
byte[] plaintext = cipher.doFinal(frame, 0, frame.length - 10);
|
|
||||||
|
|
||||||
return BackupFrame.parseFrom(plaintext);
|
|
||||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
|
||||||
throw new AssertionError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class DatabaseDowngradeException extends IOException {
|
|
||||||
DatabaseDowngradeException(int currentVersion, int backupVersion) {
|
|
||||||
super("Tried to import a backup with version " + backupVersion + " into a database with version " + currentVersion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
346
src/org/thoughtcrime/securesms/backup/FullBackupImporter.kt
Normal file
346
src/org/thoughtcrime/securesms/backup/FullBackupImporter.kt
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
package org.thoughtcrime.securesms.backup
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import net.sqlcipher.database.SQLiteDatabase
|
||||||
|
import org.greenrobot.eventbus.EventBus
|
||||||
|
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||||
|
import org.thoughtcrime.securesms.backup.BackupProtos.*
|
||||||
|
import org.thoughtcrime.securesms.crypto.AttachmentSecret
|
||||||
|
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream
|
||||||
|
import org.thoughtcrime.securesms.database.*
|
||||||
|
import org.thoughtcrime.securesms.logging.Log
|
||||||
|
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||||
|
import org.thoughtcrime.securesms.util.BackupUtil
|
||||||
|
import org.thoughtcrime.securesms.util.Conversions
|
||||||
|
import org.thoughtcrime.securesms.util.Util
|
||||||
|
import org.whispersystems.libsignal.kdf.HKDFv3
|
||||||
|
import org.whispersystems.libsignal.util.ByteUtil
|
||||||
|
import java.io.*
|
||||||
|
import java.security.InvalidAlgorithmParameterException
|
||||||
|
import java.security.InvalidKeyException
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.NoSuchAlgorithmException
|
||||||
|
import java.util.*
|
||||||
|
import javax.crypto.*
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
object FullBackupImporter {
|
||||||
|
/**
|
||||||
|
* Because BackupProtos.SharedPreference was made only to serialize string values,
|
||||||
|
* we use these 3-char prefixes to explicitly cast the values before inserting to a preference file.
|
||||||
|
*/
|
||||||
|
const val PREF_PREFIX_TYPE_INT = "i__"
|
||||||
|
const val PREF_PREFIX_TYPE_BOOLEAN = "b__"
|
||||||
|
|
||||||
|
private val TAG = FullBackupImporter::class.java.simpleName
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@WorkerThread
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun importFromUri(context: Context,
|
||||||
|
attachmentSecret: AttachmentSecret,
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
fileUri: Uri,
|
||||||
|
passphrase: String) {
|
||||||
|
|
||||||
|
val baseInputStream = context.contentResolver.openInputStream(fileUri)
|
||||||
|
?: throw IOException("Cannot open an input stream for the file URI: $fileUri")
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
try {
|
||||||
|
BackupRecordInputStream(baseInputStream, passphrase).use { inputStream ->
|
||||||
|
db.beginTransaction()
|
||||||
|
dropAllTables(db)
|
||||||
|
var frame: BackupFrame
|
||||||
|
while (!inputStream.readFrame().also { frame = it }.end) {
|
||||||
|
if (count++ % 100 == 0) EventBus.getDefault().post(BackupEvent.createProgress(count))
|
||||||
|
when {
|
||||||
|
frame.hasVersion() -> processVersion(db, frame.version)
|
||||||
|
frame.hasStatement() -> processStatement(db, frame.statement)
|
||||||
|
frame.hasPreference() -> processPreference(context, frame.preference)
|
||||||
|
frame.hasAttachment() -> processAttachment(context, attachmentSecret, db, frame.attachment, inputStream)
|
||||||
|
frame.hasSticker() -> processSticker(context, attachmentSecret, db, frame.sticker, inputStream)
|
||||||
|
frame.hasAvatar() -> processAvatar(context, frame.avatar, inputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trimEntriesForExpiredMessages(context, db)
|
||||||
|
db.setTransactionSuccessful()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (db.inTransaction()) {
|
||||||
|
db.endTransaction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EventBus.getDefault().post(BackupEvent.createFinished())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun processVersion(db: SQLiteDatabase, version: DatabaseVersion) {
|
||||||
|
if (version.version > db.version) {
|
||||||
|
throw DatabaseDowngradeException(db.version, version.version)
|
||||||
|
}
|
||||||
|
db.version = version.version
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processStatement(db: SQLiteDatabase, statement: SqlStatement) {
|
||||||
|
val isForSmsFtsSecretTable = statement.statement.contains(SearchDatabase.SMS_FTS_TABLE_NAME + "_")
|
||||||
|
val isForMmsFtsSecretTable = statement.statement.contains(SearchDatabase.MMS_FTS_TABLE_NAME + "_")
|
||||||
|
val isForSqliteSecretTable = statement.statement.toLowerCase(Locale.ENGLISH).startsWith("create table sqlite_")
|
||||||
|
if (isForSmsFtsSecretTable || isForMmsFtsSecretTable || isForSqliteSecretTable) {
|
||||||
|
Log.i(TAG, "Ignoring import for statement: " + statement.statement)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val parameters: MutableList<Any?> = LinkedList()
|
||||||
|
for (parameter in statement.parametersList) {
|
||||||
|
when {
|
||||||
|
parameter.hasStringParamter() -> parameters.add(parameter.stringParamter)
|
||||||
|
parameter.hasDoubleParameter() -> parameters.add(parameter.doubleParameter)
|
||||||
|
parameter.hasIntegerParameter() -> parameters.add(parameter.integerParameter)
|
||||||
|
parameter.hasBlobParameter() -> parameters.add(parameter.blobParameter.toByteArray())
|
||||||
|
parameter.hasNullparameter() -> parameters.add(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parameters.size > 0) {
|
||||||
|
db.execSQL(statement.statement, parameters.toTypedArray())
|
||||||
|
} else {
|
||||||
|
db.execSQL(statement.statement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun processAttachment(context: Context, attachmentSecret: AttachmentSecret,
|
||||||
|
db: SQLiteDatabase, attachment: Attachment,
|
||||||
|
inputStream: BackupRecordInputStream) {
|
||||||
|
val partsDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE)
|
||||||
|
val dataFile = File.createTempFile("part", ".mms", partsDirectory)
|
||||||
|
val output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false)
|
||||||
|
inputStream.readAttachmentTo(output.second, attachment.length)
|
||||||
|
val contentValues = ContentValues()
|
||||||
|
contentValues.put(AttachmentDatabase.DATA, dataFile.absolutePath)
|
||||||
|
contentValues.put(AttachmentDatabase.THUMBNAIL, null as String?)
|
||||||
|
contentValues.put(AttachmentDatabase.DATA_RANDOM, output.first)
|
||||||
|
db.update(AttachmentDatabase.TABLE_NAME, contentValues,
|
||||||
|
"${AttachmentDatabase.ROW_ID} = ? AND ${AttachmentDatabase.UNIQUE_ID} = ?",
|
||||||
|
arrayOf(attachment.rowId.toString(), attachment.attachmentId.toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun processSticker(context: Context, attachmentSecret: AttachmentSecret,
|
||||||
|
db: SQLiteDatabase, sticker: Sticker,
|
||||||
|
inputStream: BackupRecordInputStream) {
|
||||||
|
val stickerDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE)
|
||||||
|
val dataFile = File.createTempFile("sticker", ".mms", stickerDirectory)
|
||||||
|
val output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false)
|
||||||
|
inputStream.readAttachmentTo(output.second, sticker.length)
|
||||||
|
val contentValues = ContentValues()
|
||||||
|
contentValues.put(StickerDatabase.FILE_PATH, dataFile.absolutePath)
|
||||||
|
contentValues.put(StickerDatabase.FILE_RANDOM, output.first)
|
||||||
|
db.update(StickerDatabase.TABLE_NAME, contentValues,
|
||||||
|
StickerDatabase._ID + " = ?", arrayOf(sticker.rowId.toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun processAvatar(context: Context, avatar: Avatar, inputStream: BackupRecordInputStream) {
|
||||||
|
inputStream.readAttachmentTo(FileOutputStream(
|
||||||
|
AvatarHelper.getAvatarFile(context, Address.fromExternal(context, avatar.name))), avatar.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ApplySharedPref")
|
||||||
|
private fun processPreference(context: Context, preference: SharedPreference) {
|
||||||
|
val preferences = context.getSharedPreferences(preference.file, 0)
|
||||||
|
val key = preference.key
|
||||||
|
val value = preference.value
|
||||||
|
|
||||||
|
// See the comment next to PREF_PREFIX_TYPE_* constants.
|
||||||
|
when {
|
||||||
|
key.startsWith(PREF_PREFIX_TYPE_INT) ->
|
||||||
|
preferences.edit().putInt(
|
||||||
|
key.substring(PREF_PREFIX_TYPE_INT.length),
|
||||||
|
value.toInt()
|
||||||
|
).commit()
|
||||||
|
key.startsWith(PREF_PREFIX_TYPE_BOOLEAN) ->
|
||||||
|
preferences.edit().putBoolean(
|
||||||
|
key.substring(PREF_PREFIX_TYPE_BOOLEAN.length),
|
||||||
|
value.toBoolean()
|
||||||
|
).commit()
|
||||||
|
else ->
|
||||||
|
preferences.edit().putString(key, value).commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dropAllTables(db: SQLiteDatabase) {
|
||||||
|
db.rawQuery("SELECT name, type FROM sqlite_master", null).use { cursor ->
|
||||||
|
while (cursor != null && cursor.moveToNext()) {
|
||||||
|
val name = cursor.getString(0)
|
||||||
|
val type = cursor.getString(1)
|
||||||
|
if ("table" == type && !name.startsWith("sqlite_")) {
|
||||||
|
db.execSQL("DROP TABLE IF EXISTS $name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trimEntriesForExpiredMessages(context: Context, db: SQLiteDatabase) {
|
||||||
|
val trimmedCondition = " NOT IN (SELECT ${MmsDatabase.ID} FROM ${MmsDatabase.TABLE_NAME})"
|
||||||
|
db.delete(GroupReceiptDatabase.TABLE_NAME, GroupReceiptDatabase.MMS_ID + trimmedCondition, null)
|
||||||
|
val columns = arrayOf(AttachmentDatabase.ROW_ID, AttachmentDatabase.UNIQUE_ID)
|
||||||
|
val where = AttachmentDatabase.MMS_ID + trimmedCondition
|
||||||
|
db.query(AttachmentDatabase.TABLE_NAME, columns, where, null, null, null, null).use { cursor ->
|
||||||
|
while (cursor != null && cursor.moveToNext()) {
|
||||||
|
DatabaseFactory.getAttachmentDatabase(context)
|
||||||
|
.deleteAttachment(AttachmentId(cursor.getLong(0), cursor.getLong(1)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.query(ThreadDatabase.TABLE_NAME, arrayOf(ThreadDatabase.ID),
|
||||||
|
ThreadDatabase.EXPIRES_IN + " > 0", null, null, null, null).use { cursor ->
|
||||||
|
while (cursor != null && cursor.moveToNext()) {
|
||||||
|
DatabaseFactory.getThreadDatabase(context).update(cursor.getLong(0), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class BackupRecordInputStream : Closeable {
|
||||||
|
private val inputStream: InputStream
|
||||||
|
private val cipher: Cipher
|
||||||
|
private val mac: Mac
|
||||||
|
private val cipherKey: ByteArray
|
||||||
|
private val macKey: ByteArray
|
||||||
|
private val iv: ByteArray
|
||||||
|
|
||||||
|
private var counter = 0
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
constructor(inputStream: InputStream, passphrase: String) : super() {
|
||||||
|
try {
|
||||||
|
this.inputStream = inputStream
|
||||||
|
val headerLengthBytes = ByteArray(4)
|
||||||
|
Util.readFully(this.inputStream, headerLengthBytes)
|
||||||
|
val headerLength = Conversions.byteArrayToInt(headerLengthBytes)
|
||||||
|
val headerFrame = ByteArray(headerLength)
|
||||||
|
Util.readFully(this.inputStream, headerFrame)
|
||||||
|
val frame = BackupFrame.parseFrom(headerFrame)
|
||||||
|
if (!frame.hasHeader()) {
|
||||||
|
throw IOException("Backup stream does not start with header!")
|
||||||
|
}
|
||||||
|
val header = frame.header
|
||||||
|
iv = header.iv.toByteArray()
|
||||||
|
if (iv.size != 16) {
|
||||||
|
throw IOException("Invalid IV length!")
|
||||||
|
}
|
||||||
|
val key = BackupUtil.computeBackupKey(passphrase, if (header.hasSalt()) header.salt.toByteArray() else null)
|
||||||
|
val derived = HKDFv3().deriveSecrets(key, "Backup Export".toByteArray(), 64)
|
||||||
|
val split = ByteUtil.split(derived, 32, 32)
|
||||||
|
cipherKey = split[0]
|
||||||
|
macKey = split[1]
|
||||||
|
cipher = Cipher.getInstance("AES/CTR/NoPadding")
|
||||||
|
mac = Mac.getInstance("HmacSHA256")
|
||||||
|
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
|
||||||
|
counter = Conversions.byteArrayToInt(iv)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
when (e) {
|
||||||
|
is NoSuchAlgorithmException,
|
||||||
|
is NoSuchPaddingException,
|
||||||
|
is InvalidKeyException -> {
|
||||||
|
throw AssertionError(e)
|
||||||
|
}
|
||||||
|
else -> throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun readFrame(): BackupFrame {
|
||||||
|
return readFrame(inputStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun readAttachmentTo(out: OutputStream, length: Int) {
|
||||||
|
var length = length
|
||||||
|
try {
|
||||||
|
Conversions.intToByteArray(iv, 0, counter++)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||||
|
mac.update(iv)
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
while (length > 0) {
|
||||||
|
val read = inputStream.read(buffer, 0, Math.min(buffer.size, length))
|
||||||
|
if (read == -1) throw IOException("File ended early!")
|
||||||
|
mac.update(buffer, 0, read)
|
||||||
|
val plaintext = cipher.update(buffer, 0, read)
|
||||||
|
if (plaintext != null) {
|
||||||
|
out.write(plaintext, 0, plaintext.size)
|
||||||
|
}
|
||||||
|
length -= read
|
||||||
|
}
|
||||||
|
val plaintext = cipher.doFinal()
|
||||||
|
if (plaintext != null) {
|
||||||
|
out.write(plaintext, 0, plaintext.size)
|
||||||
|
}
|
||||||
|
out.close()
|
||||||
|
val ourMac = ByteUtil.trim(mac.doFinal(), 10)
|
||||||
|
val theirMac = ByteArray(10)
|
||||||
|
try {
|
||||||
|
Util.readFully(inputStream, theirMac)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw IOException(e)
|
||||||
|
}
|
||||||
|
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
||||||
|
throw IOException("Bad MAC")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
when (e) {
|
||||||
|
is InvalidKeyException,
|
||||||
|
is InvalidAlgorithmParameterException,
|
||||||
|
is IllegalBlockSizeException,
|
||||||
|
is BadPaddingException -> {
|
||||||
|
throw AssertionError(e)
|
||||||
|
}
|
||||||
|
else -> throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun readFrame(`in`: InputStream?): BackupFrame {
|
||||||
|
return try {
|
||||||
|
val length = ByteArray(4)
|
||||||
|
Util.readFully(`in`, length)
|
||||||
|
val frame = ByteArray(Conversions.byteArrayToInt(length))
|
||||||
|
Util.readFully(`in`, frame)
|
||||||
|
val theirMac = ByteArray(10)
|
||||||
|
System.arraycopy(frame, frame.size - 10, theirMac, 0, theirMac.size)
|
||||||
|
mac.update(frame, 0, frame.size - 10)
|
||||||
|
val ourMac = ByteUtil.trim(mac.doFinal(), 10)
|
||||||
|
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
||||||
|
throw IOException("Bad MAC")
|
||||||
|
}
|
||||||
|
Conversions.intToByteArray(iv, 0, counter++)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
|
||||||
|
val plaintext = cipher.doFinal(frame, 0, frame.size - 10)
|
||||||
|
BackupFrame.parseFrom(plaintext)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
when (e) {
|
||||||
|
is InvalidKeyException,
|
||||||
|
is InvalidAlgorithmParameterException,
|
||||||
|
is IllegalBlockSizeException,
|
||||||
|
is BadPaddingException -> {
|
||||||
|
throw AssertionError(e)
|
||||||
|
}
|
||||||
|
else -> throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun close() {
|
||||||
|
inputStream.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DatabaseDowngradeException internal constructor(currentVersion: Int, backupVersion: Int) :
|
||||||
|
IOException("Tried to import a backup with version $backupVersion into a database with version $currentVersion")
|
||||||
|
}
|
@ -548,12 +548,12 @@ public class ConversationFragment extends Fragment
|
|||||||
}
|
}
|
||||||
} else if (!ignoredMessages.contains(serverID)) {
|
} else if (!ignoredMessages.contains(serverID)) {
|
||||||
failedMessages.add(messageRecord.getId());
|
failedMessages.add(messageRecord.getId());
|
||||||
Log.d("Loki", "Failed to delete message: " + messageRecord.getId() + ".");
|
Log.w("Loki", "Failed to delete message: " + messageRecord.getId() + ".");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}). fail(e -> {
|
}). fail(e -> {
|
||||||
Log.d("Loki", "Couldn't delete message due to error: " + e.toString() + ".");
|
Log.w("Loki", "Couldn't delete message due to error: " + e.toString() + ".");
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -122,21 +122,42 @@ public class IdentityKeyUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<BackupProtos.SharedPreference> getBackupRecord(@NonNull Context context) {
|
public static List<BackupProtos.SharedPreference> getBackupRecords(@NonNull Context context) {
|
||||||
SharedPreferences preferences = context.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0);
|
SharedPreferences preferences = context.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0);
|
||||||
|
|
||||||
return new LinkedList<BackupProtos.SharedPreference>() {{
|
LinkedList<BackupProtos.SharedPreference> prefList = new LinkedList<>();
|
||||||
add(BackupProtos.SharedPreference.newBuilder()
|
|
||||||
.setFile(MasterSecretUtil.PREFERENCES_NAME)
|
prefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||||
.setKey(IDENTITY_PUBLIC_KEY_PREF)
|
.setFile(MasterSecretUtil.PREFERENCES_NAME)
|
||||||
.setValue(preferences.getString(IDENTITY_PUBLIC_KEY_PREF, null))
|
.setKey(IDENTITY_PUBLIC_KEY_PREF)
|
||||||
.build());
|
.setValue(preferences.getString(IDENTITY_PUBLIC_KEY_PREF, null))
|
||||||
add(BackupProtos.SharedPreference.newBuilder()
|
.build());
|
||||||
.setFile(MasterSecretUtil.PREFERENCES_NAME)
|
prefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||||
.setKey(IDENTITY_PRIVATE_KEY_PREF)
|
.setFile(MasterSecretUtil.PREFERENCES_NAME)
|
||||||
.setValue(preferences.getString(IDENTITY_PRIVATE_KEY_PREF, null))
|
.setKey(IDENTITY_PRIVATE_KEY_PREF)
|
||||||
.build());
|
.setValue(preferences.getString(IDENTITY_PRIVATE_KEY_PREF, null))
|
||||||
}};
|
.build());
|
||||||
|
if (preferences.contains(ED25519_PUBLIC_KEY)) {
|
||||||
|
prefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||||
|
.setFile(MasterSecretUtil.PREFERENCES_NAME)
|
||||||
|
.setKey(ED25519_PUBLIC_KEY)
|
||||||
|
.setValue(preferences.getString(ED25519_PUBLIC_KEY, null))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
if (preferences.contains(ED25519_SECRET_KEY)) {
|
||||||
|
prefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||||
|
.setFile(MasterSecretUtil.PREFERENCES_NAME)
|
||||||
|
.setKey(ED25519_SECRET_KEY)
|
||||||
|
.setValue(preferences.getString(ED25519_SECRET_KEY, null))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
prefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||||
|
.setFile(MasterSecretUtil.PREFERENCES_NAME)
|
||||||
|
.setKey(LOKI_SEED)
|
||||||
|
.setValue(preferences.getString(LOKI_SEED, null))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
return prefList;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean hasLegacyIdentityKeys(Context context) {
|
private static boolean hasLegacyIdentityKeys(Context context) {
|
||||||
|
@ -17,11 +17,11 @@ import java.util.Set;
|
|||||||
|
|
||||||
public class DraftDatabase extends Database {
|
public class DraftDatabase extends Database {
|
||||||
|
|
||||||
private static final String TABLE_NAME = "drafts";
|
public static final String TABLE_NAME = "drafts";
|
||||||
public static final String ID = "_id";
|
public static final String ID = "_id";
|
||||||
public static final String THREAD_ID = "thread_id";
|
public static final String THREAD_ID = "thread_id";
|
||||||
public static final String DRAFT_TYPE = "type";
|
public static final String DRAFT_TYPE = "type";
|
||||||
public static final String DRAFT_VALUE = "value";
|
public static final String DRAFT_VALUE = "value";
|
||||||
|
|
||||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
|
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
|
||||||
THREAD_ID + " INTEGER, " + DRAFT_TYPE + " TEXT, " + DRAFT_VALUE + " TEXT);";
|
THREAD_ID + " INTEGER, " + DRAFT_TYPE + " TEXT, " + DRAFT_VALUE + " TEXT);";
|
||||||
|
@ -22,8 +22,8 @@ public class JobDatabase extends Database {
|
|||||||
Constraints.CREATE_TABLE,
|
Constraints.CREATE_TABLE,
|
||||||
Dependencies.CREATE_TABLE };
|
Dependencies.CREATE_TABLE };
|
||||||
|
|
||||||
private static final class Jobs {
|
public static final class Jobs {
|
||||||
private static final String TABLE_NAME = "job_spec";
|
public static final String TABLE_NAME = "job_spec";
|
||||||
private static final String ID = "_id";
|
private static final String ID = "_id";
|
||||||
private static final String JOB_SPEC_ID = "job_spec_id";
|
private static final String JOB_SPEC_ID = "job_spec_id";
|
||||||
private static final String FACTORY_KEY = "factory_key";
|
private static final String FACTORY_KEY = "factory_key";
|
||||||
@ -53,8 +53,8 @@ public class JobDatabase extends Database {
|
|||||||
IS_RUNNING + " INTEGER)";
|
IS_RUNNING + " INTEGER)";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class Constraints {
|
public static final class Constraints {
|
||||||
private static final String TABLE_NAME = "constraint_spec";
|
public static final String TABLE_NAME = "constraint_spec";
|
||||||
private static final String ID = "_id";
|
private static final String ID = "_id";
|
||||||
private static final String JOB_SPEC_ID = "job_spec_id";
|
private static final String JOB_SPEC_ID = "job_spec_id";
|
||||||
private static final String FACTORY_KEY = "factory_key";
|
private static final String FACTORY_KEY = "factory_key";
|
||||||
@ -65,8 +65,8 @@ public class JobDatabase extends Database {
|
|||||||
"UNIQUE(" + JOB_SPEC_ID + ", " + FACTORY_KEY + "))";
|
"UNIQUE(" + JOB_SPEC_ID + ", " + FACTORY_KEY + "))";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class Dependencies {
|
public static final class Dependencies {
|
||||||
private static final String TABLE_NAME = "dependency_spec";
|
public static final String TABLE_NAME = "dependency_spec";
|
||||||
private static final String ID = "_id";
|
private static final String ID = "_id";
|
||||||
private static final String JOB_SPEC_ID = "job_spec_id";
|
private static final String JOB_SPEC_ID = "job_spec_id";
|
||||||
private static final String DEPENDS_ON_JOB_SPEC_ID = "depends_on_job_spec_id";
|
private static final String DEPENDS_ON_JOB_SPEC_ID = "depends_on_job_spec_id";
|
||||||
|
@ -20,16 +20,16 @@ public class PushDatabase extends Database {
|
|||||||
|
|
||||||
private static final String TAG = PushDatabase.class.getSimpleName();
|
private static final String TAG = PushDatabase.class.getSimpleName();
|
||||||
|
|
||||||
private static final String TABLE_NAME = "push";
|
public static final String TABLE_NAME = "push";
|
||||||
public static final String ID = "_id";
|
public static final String ID = "_id";
|
||||||
public static final String TYPE = "type";
|
public static final String TYPE = "type";
|
||||||
public static final String SOURCE = "source";
|
public static final String SOURCE = "source";
|
||||||
public static final String DEVICE_ID = "device_id";
|
public static final String DEVICE_ID = "device_id";
|
||||||
public static final String LEGACY_MSG = "body";
|
public static final String LEGACY_MSG = "body";
|
||||||
public static final String CONTENT = "content";
|
public static final String CONTENT = "content";
|
||||||
public static final String TIMESTAMP = "timestamp";
|
public static final String TIMESTAMP = "timestamp";
|
||||||
public static final String SERVER_TIMESTAMP = "server_timestamp";
|
public static final String SERVER_TIMESTAMP = "server_timestamp";
|
||||||
public static final String SERVER_GUID = "server_guid";
|
public static final String SERVER_GUID = "server_guid";
|
||||||
|
|
||||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
|
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
|
||||||
TYPE + " INTEGER, " + SOURCE + " TEXT, " + DEVICE_ID + " INTEGER, " + LEGACY_MSG + " TEXT, " + CONTENT + " TEXT, " + TIMESTAMP + " INTEGER, " +
|
TYPE + " INTEGER, " + SOURCE + " TEXT, " + DEVICE_ID + " INTEGER, " + LEGACY_MSG + " TEXT, " + CONTENT + " TEXT, " + TIMESTAMP + " INTEGER, " +
|
||||||
|
@ -50,12 +50,15 @@ import org.whispersystems.libsignal.util.Pair;
|
|||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
import org.whispersystems.libsignal.util.guava.Optional;
|
||||||
|
|
||||||
import java.io.Closeable;
|
import java.io.Closeable;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
|
import nl.komponents.kovenant.combine.Tuple2;
|
||||||
|
|
||||||
public class ThreadDatabase extends Database {
|
public class ThreadDatabase extends Database {
|
||||||
|
|
||||||
private static final String TAG = ThreadDatabase.class.getSimpleName();
|
private static final String TAG = ThreadDatabase.class.getSimpleName();
|
||||||
@ -618,6 +621,34 @@ public class ThreadDatabase extends Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lightweight utility method to retrieve the complete list of non-archived threads coupled with their recipient address.
|
||||||
|
* @return a tuple with non-null values: thread id, recipient address.
|
||||||
|
*/
|
||||||
|
public @NonNull List<Tuple2<Long, String>> getConversationListQuick() {
|
||||||
|
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||||
|
|
||||||
|
ArrayList<Tuple2<Long, String>> result = new ArrayList<>();
|
||||||
|
|
||||||
|
try (Cursor cursor = db.query(
|
||||||
|
TABLE_NAME,
|
||||||
|
new String[]{ID, ADDRESS},
|
||||||
|
ARCHIVED + " = 0 AND " + MESSAGE_COUNT + " != 0 AND " + ADDRESS + " IS NOT NULL",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null)) {
|
||||||
|
while (cursor != null && cursor.moveToNext()) {
|
||||||
|
result.add(new Tuple2<>(
|
||||||
|
cursor.getLong(cursor.getColumnIndexOrThrow(ID)),
|
||||||
|
cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) {
|
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) {
|
||||||
if (messageRecord.isMms()) {
|
if (messageRecord.isMms()) {
|
||||||
MmsMessageRecord record = (MmsMessageRecord) messageRecord;
|
MmsMessageRecord record = (MmsMessageRecord) messageRecord;
|
||||||
|
@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.jobs.TrimThreadJob;
|
|||||||
import org.thoughtcrime.securesms.jobs.TypingSendJob;
|
import org.thoughtcrime.securesms.jobs.TypingSendJob;
|
||||||
import org.thoughtcrime.securesms.jobs.UpdateApkJob;
|
import org.thoughtcrime.securesms.jobs.UpdateApkJob;
|
||||||
import org.thoughtcrime.securesms.loki.api.PrepareAttachmentAudioExtrasJob;
|
import org.thoughtcrime.securesms.loki.api.PrepareAttachmentAudioExtrasJob;
|
||||||
|
import org.thoughtcrime.securesms.loki.api.ResetThreadSessionJob;
|
||||||
import org.thoughtcrime.securesms.loki.protocol.ClosedGroupUpdateMessageSendJob;
|
import org.thoughtcrime.securesms.loki.protocol.ClosedGroupUpdateMessageSendJob;
|
||||||
import org.thoughtcrime.securesms.loki.protocol.NullMessageSendJob;
|
import org.thoughtcrime.securesms.loki.protocol.NullMessageSendJob;
|
||||||
import org.thoughtcrime.securesms.loki.protocol.SessionRequestMessageSendJob;
|
import org.thoughtcrime.securesms.loki.protocol.SessionRequestMessageSendJob;
|
||||||
@ -102,6 +103,7 @@ public class WorkManagerFactoryMappings {
|
|||||||
put(TypingSendJob.class.getName(), TypingSendJob.KEY);
|
put(TypingSendJob.class.getName(), TypingSendJob.KEY);
|
||||||
put(UpdateApkJob.class.getName(), UpdateApkJob.KEY);
|
put(UpdateApkJob.class.getName(), UpdateApkJob.KEY);
|
||||||
put(PrepareAttachmentAudioExtrasJob.class.getName(), PrepareAttachmentAudioExtrasJob.KEY);
|
put(PrepareAttachmentAudioExtrasJob.class.getName(), PrepareAttachmentAudioExtrasJob.KEY);
|
||||||
|
put(ResetThreadSessionJob.class.getName(), ResetThreadSessionJob.KEY);
|
||||||
}};
|
}};
|
||||||
|
|
||||||
public static @Nullable String getFactoryKey(@NonNull String workManagerClass) {
|
public static @Nullable String getFactoryKey(@NonNull String workManagerClass) {
|
||||||
|
@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkOrCellServiceConstraint
|
|||||||
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraint;
|
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraint;
|
||||||
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintObserver;
|
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintObserver;
|
||||||
import org.thoughtcrime.securesms.loki.api.PrepareAttachmentAudioExtrasJob;
|
import org.thoughtcrime.securesms.loki.api.PrepareAttachmentAudioExtrasJob;
|
||||||
|
import org.thoughtcrime.securesms.loki.api.ResetThreadSessionJob;
|
||||||
import org.thoughtcrime.securesms.loki.protocol.ClosedGroupUpdateMessageSendJob;
|
import org.thoughtcrime.securesms.loki.protocol.ClosedGroupUpdateMessageSendJob;
|
||||||
import org.thoughtcrime.securesms.loki.protocol.NullMessageSendJob;
|
import org.thoughtcrime.securesms.loki.protocol.NullMessageSendJob;
|
||||||
import org.thoughtcrime.securesms.loki.protocol.SessionRequestMessageSendJob;
|
import org.thoughtcrime.securesms.loki.protocol.SessionRequestMessageSendJob;
|
||||||
@ -79,6 +80,7 @@ public final class JobManagerFactories {
|
|||||||
put(TypingSendJob.KEY, new TypingSendJob.Factory());
|
put(TypingSendJob.KEY, new TypingSendJob.Factory());
|
||||||
put(UpdateApkJob.KEY, new UpdateApkJob.Factory());
|
put(UpdateApkJob.KEY, new UpdateApkJob.Factory());
|
||||||
put(PrepareAttachmentAudioExtrasJob.KEY, new PrepareAttachmentAudioExtrasJob.Factory());
|
put(PrepareAttachmentAudioExtrasJob.KEY, new PrepareAttachmentAudioExtrasJob.Factory());
|
||||||
|
put(ResetThreadSessionJob.KEY, new ResetThreadSessionJob.Factory());
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
package org.thoughtcrime.securesms.jobs;
|
package org.thoughtcrime.securesms.jobs;
|
||||||
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.database.NoExternalStorageException;
|
import org.thoughtcrime.securesms.database.NoExternalStorageException;
|
||||||
import org.thoughtcrime.securesms.loki.database.BackupFileRecord;
|
|
||||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
|
import org.thoughtcrime.securesms.loki.database.BackupFileRecord;
|
||||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||||
import org.thoughtcrime.securesms.service.GenericForegroundService;
|
import org.thoughtcrime.securesms.service.GenericForegroundService;
|
||||||
import org.thoughtcrime.securesms.util.BackupUtil;
|
import org.thoughtcrime.securesms.util.BackupUtil;
|
||||||
|
@ -0,0 +1,219 @@
|
|||||||
|
package org.thoughtcrime.securesms.loki.activities
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import android.text.Spannable
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.method.LinkMovementMethod
|
||||||
|
import android.text.style.ClickableSpan
|
||||||
|
import android.text.style.StyleSpan
|
||||||
|
import android.view.View
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.ActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.databinding.DataBindingUtil
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.google.android.gms.common.util.Strings
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import network.loki.messenger.R
|
||||||
|
import network.loki.messenger.databinding.ActivityBackupRestoreBinding
|
||||||
|
import org.thoughtcrime.securesms.ApplicationContext
|
||||||
|
import org.thoughtcrime.securesms.BaseActionBarActivity
|
||||||
|
import org.thoughtcrime.securesms.backup.FullBackupImporter
|
||||||
|
import org.thoughtcrime.securesms.backup.FullBackupImporter.DatabaseDowngradeException
|
||||||
|
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
|
||||||
|
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||||
|
import org.thoughtcrime.securesms.logging.Log
|
||||||
|
import org.thoughtcrime.securesms.loki.utilities.setUpActionBarSessionLogo
|
||||||
|
import org.thoughtcrime.securesms.loki.utilities.show
|
||||||
|
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||||
|
import org.thoughtcrime.securesms.util.BackupUtil
|
||||||
|
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||||
|
|
||||||
|
class BackupRestoreActivity : BaseActionBarActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BackupRestoreActivity"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val viewModel by viewModels<BackupRestoreViewModel>()
|
||||||
|
|
||||||
|
private val fileSelectionResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result: ActivityResult ->
|
||||||
|
if (result.resultCode == Activity.RESULT_OK && result.data != null && result.data!!.data != null) {
|
||||||
|
viewModel.backupFile.value = result.data!!.data!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setUpActionBarSessionLogo()
|
||||||
|
|
||||||
|
val viewBinding = DataBindingUtil.setContentView<ActivityBackupRestoreBinding>(this, R.layout.activity_backup_restore)
|
||||||
|
viewBinding.lifecycleOwner = this
|
||||||
|
viewBinding.viewModel = viewModel
|
||||||
|
|
||||||
|
viewBinding.restoreButton.setOnClickListener { viewModel.tryRestoreBackup() }
|
||||||
|
|
||||||
|
viewBinding.buttonSelectFile.setOnClickListener {
|
||||||
|
fileSelectionResultLauncher.launch(Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||||
|
//FIXME On some old APIs (tested on 21 & 23) the mime type doesn't filter properly
|
||||||
|
// and the backup files are unavailable for selection.
|
||||||
|
// type = BackupUtil.BACKUP_FILE_MIME_TYPE
|
||||||
|
type = "*/*"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
viewBinding.backupCode.addTextChangedListener { text -> viewModel.backupPassphrase.value = text.toString() }
|
||||||
|
|
||||||
|
// Focus passphrase text edit when backup file is selected.
|
||||||
|
viewModel.backupFile.observe(this, { backupFile ->
|
||||||
|
if (backupFile != null) viewBinding.backupCode.post {
|
||||||
|
viewBinding.backupCode.requestFocus()
|
||||||
|
(getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager)
|
||||||
|
.showSoftInput(viewBinding.backupCode, InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// React to backup import result.
|
||||||
|
viewModel.backupImportResult.observe(this) { result ->
|
||||||
|
if (result != null) when (result) {
|
||||||
|
BackupRestoreViewModel.BackupRestoreResult.SUCCESS -> {
|
||||||
|
val intent = Intent(this, HomeActivity::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
this.show(intent)
|
||||||
|
}
|
||||||
|
BackupRestoreViewModel.BackupRestoreResult.FAILURE_VERSION_DOWNGRADE ->
|
||||||
|
Toast.makeText(this, R.string.RegistrationActivity_backup_failure_downgrade, Toast.LENGTH_LONG).show()
|
||||||
|
BackupRestoreViewModel.BackupRestoreResult.FAILURE_UNKNOWN ->
|
||||||
|
Toast.makeText(this, R.string.RegistrationActivity_incorrect_backup_passphrase, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//region Legal info views
|
||||||
|
val termsExplanation = SpannableStringBuilder("By using this service, you agree to our Terms of Service and Privacy Policy")
|
||||||
|
termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
termsExplanation.setSpan(object : ClickableSpan() {
|
||||||
|
override fun onClick(widget: View) {
|
||||||
|
openURL("https://getsession.org/terms-of-service/")
|
||||||
|
}
|
||||||
|
}, 40, 56, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
termsExplanation.setSpan(StyleSpan(Typeface.BOLD), 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
termsExplanation.setSpan(object : ClickableSpan() {
|
||||||
|
override fun onClick(widget: View) {
|
||||||
|
openURL("https://getsession.org/privacy-policy/")
|
||||||
|
}
|
||||||
|
}, 61, 75, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
viewBinding.termsTextView.movementMethod = LinkMovementMethod.getInstance()
|
||||||
|
viewBinding.termsTextView.text = termsExplanation
|
||||||
|
//endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openURL(url: String) {
|
||||||
|
try {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
|
startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(this, R.string.invalid_url, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackupRestoreViewModel(application: Application): AndroidViewModel(application) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BackupRestoreViewModel"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun uriToFileName(view: View, fileUri: Uri?): String? {
|
||||||
|
fileUri ?: return null
|
||||||
|
|
||||||
|
view.context.contentResolver.query(fileUri, null, null, null, null).use {
|
||||||
|
val nameIndex = it!!.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
it.moveToFirst()
|
||||||
|
return it.getString(nameIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun validateData(fileUri: Uri?, passphrase: String?): Boolean {
|
||||||
|
return fileUri != null &&
|
||||||
|
!Strings.isEmptyOrWhitespace(passphrase) &&
|
||||||
|
passphrase!!.length == BackupUtil.BACKUP_PASSPHRASE_LENGTH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val backupFile = MutableLiveData<Uri>(null)
|
||||||
|
val backupPassphrase = MutableLiveData<String>(null)
|
||||||
|
|
||||||
|
val processingBackupFile = MutableLiveData<Boolean>(false)
|
||||||
|
val backupImportResult = MutableLiveData<BackupRestoreResult>(null)
|
||||||
|
|
||||||
|
fun tryRestoreBackup() = viewModelScope.launch {
|
||||||
|
if (processingBackupFile.value == true) return@launch
|
||||||
|
if (backupImportResult.value == BackupRestoreResult.SUCCESS) return@launch
|
||||||
|
if (!validateData(backupFile.value, backupPassphrase.value)) return@launch
|
||||||
|
|
||||||
|
val context = getApplication<Application>()
|
||||||
|
val backupFile = backupFile.value!!
|
||||||
|
val passphrase = backupPassphrase.value!!
|
||||||
|
|
||||||
|
val result: BackupRestoreResult
|
||||||
|
|
||||||
|
processingBackupFile.value = true
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
result = try {
|
||||||
|
val database = DatabaseFactory.getBackupDatabase(context)
|
||||||
|
FullBackupImporter.importFromUri(
|
||||||
|
context,
|
||||||
|
AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
|
||||||
|
DatabaseFactory.getBackupDatabase(context),
|
||||||
|
backupFile,
|
||||||
|
passphrase
|
||||||
|
)
|
||||||
|
DatabaseFactory.upgradeRestored(context, database)
|
||||||
|
NotificationChannels.restoreContactNotificationChannels(context)
|
||||||
|
TextSecurePreferences.setRestorationTime(context, System.currentTimeMillis())
|
||||||
|
TextSecurePreferences.setHasViewedSeed(context, true)
|
||||||
|
TextSecurePreferences.setHasSeenWelcomeScreen(context, true)
|
||||||
|
TextSecurePreferences.setPromptedPushRegistration(context, true)
|
||||||
|
TextSecurePreferences.setHasSeenMultiDeviceRemovalSheet(context)
|
||||||
|
TextSecurePreferences.setHasSeenLightThemeIntroSheet(context)
|
||||||
|
val application = ApplicationContext.getInstance(context)
|
||||||
|
application.setUpStorageAPIIfNeeded()
|
||||||
|
application.setUpP2PAPIIfNeeded()
|
||||||
|
|
||||||
|
HomeActivity.requestResetAllSessionsOnStartup(context)
|
||||||
|
|
||||||
|
BackupRestoreResult.SUCCESS
|
||||||
|
} catch (e: DatabaseDowngradeException) {
|
||||||
|
Log.w(TAG, "Failed due to the backup being from a newer version of Signal.", e)
|
||||||
|
BackupRestoreResult.FAILURE_VERSION_DOWNGRADE
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, e)
|
||||||
|
BackupRestoreResult.FAILURE_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processingBackupFile.value = false
|
||||||
|
|
||||||
|
backupImportResult.value = result
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class BackupRestoreResult {
|
||||||
|
SUCCESS, FAILURE_VERSION_DOWNGRADE, FAILURE_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
@ -30,11 +30,13 @@ import network.loki.messenger.R
|
|||||||
import org.thoughtcrime.securesms.ApplicationContext
|
import org.thoughtcrime.securesms.ApplicationContext
|
||||||
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
|
||||||
import org.thoughtcrime.securesms.conversation.ConversationActivity
|
import org.thoughtcrime.securesms.conversation.ConversationActivity
|
||||||
|
import org.thoughtcrime.securesms.database.Address
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||||
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
import org.thoughtcrime.securesms.database.model.ThreadRecord
|
||||||
import org.thoughtcrime.securesms.groups.GroupManager
|
import org.thoughtcrime.securesms.groups.GroupManager
|
||||||
import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob
|
import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob
|
||||||
|
import org.thoughtcrime.securesms.loki.api.ResetThreadSessionJob
|
||||||
import org.thoughtcrime.securesms.loki.dialogs.ConversationOptionsBottomSheet
|
import org.thoughtcrime.securesms.loki.dialogs.ConversationOptionsBottomSheet
|
||||||
import org.thoughtcrime.securesms.loki.dialogs.LightThemeFeatureIntroBottomSheet
|
import org.thoughtcrime.securesms.loki.dialogs.LightThemeFeatureIntroBottomSheet
|
||||||
import org.thoughtcrime.securesms.loki.dialogs.MultiDeviceRemovalBottomSheet
|
import org.thoughtcrime.securesms.loki.dialogs.MultiDeviceRemovalBottomSheet
|
||||||
@ -48,6 +50,8 @@ import org.thoughtcrime.securesms.loki.views.SeedReminderViewDelegate
|
|||||||
import org.thoughtcrime.securesms.mms.GlideApp
|
import org.thoughtcrime.securesms.mms.GlideApp
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||||
|
import org.thoughtcrime.securesms.util.TextSecurePreferences.getBooleanPreference
|
||||||
|
import org.thoughtcrime.securesms.util.TextSecurePreferences.setBooleanPreference
|
||||||
import org.thoughtcrime.securesms.util.Util
|
import org.thoughtcrime.securesms.util.Util
|
||||||
import org.whispersystems.signalservice.loki.api.fileserver.FileServerAPI
|
import org.whispersystems.signalservice.loki.api.fileserver.FileServerAPI
|
||||||
import org.whispersystems.signalservice.loki.protocol.mentions.MentionsManager
|
import org.whispersystems.signalservice.loki.protocol.mentions.MentionsManager
|
||||||
@ -59,6 +63,32 @@ import org.whispersystems.signalservice.loki.utilities.toHexString
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListener, SeedReminderViewDelegate, NewConversationButtonSetViewDelegate {
|
class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListener, SeedReminderViewDelegate, NewConversationButtonSetViewDelegate {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREF_RESET_ALL_SESSIONS_ON_START_UP = "pref_reset_all_sessions_on_start_up"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun requestResetAllSessionsOnStartup(context: Context) {
|
||||||
|
setBooleanPreference(context, PREF_RESET_ALL_SESSIONS_ON_START_UP, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun scheduleResetAllSessionsIfRequested(context: Context) {
|
||||||
|
if (!getBooleanPreference(context, PREF_RESET_ALL_SESSIONS_ON_START_UP, false)) return
|
||||||
|
setBooleanPreference(context, PREF_RESET_ALL_SESSIONS_ON_START_UP, false)
|
||||||
|
|
||||||
|
val jobManager = ApplicationContext.getInstance(context).jobManager
|
||||||
|
|
||||||
|
DatabaseFactory.getThreadDatabase(context).conversationListQuick.forEach { tuple ->
|
||||||
|
val threadId: Long = tuple.first
|
||||||
|
val recipientAddress: String = tuple.second
|
||||||
|
jobManager.add(ResetThreadSessionJob(
|
||||||
|
Address.fromSerialized(recipientAddress),
|
||||||
|
threadId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private lateinit var glide: GlideRequests
|
private lateinit var glide: GlideRequests
|
||||||
private var broadcastReceiver: BroadcastReceiver? = null
|
private var broadcastReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
@ -180,6 +210,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity, ConversationClickListe
|
|||||||
TextSecurePreferences.setWasUnlinked(this, true)
|
TextSecurePreferences.setWasUnlinked(this, true)
|
||||||
ApplicationContext.getInstance(this).clearData()
|
ApplicationContext.getInstance(this).clearData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Perform chat sessions reset if requested (usually happens after backup restoration).
|
||||||
|
scheduleResetAllSessionsIfRequested(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
@ -44,6 +44,10 @@ class LandingActivity : BaseActionBarActivity(), LinkDeviceSlaveModeDialogDelega
|
|||||||
fakeChatView.startAnimating()
|
fakeChatView.startAnimating()
|
||||||
registerButton.setOnClickListener { register() }
|
registerButton.setOnClickListener { register() }
|
||||||
restoreButton.setOnClickListener { restore() }
|
restoreButton.setOnClickListener { restore() }
|
||||||
|
restoreBackupButton.setOnClickListener {
|
||||||
|
val intent = Intent(this, BackupRestoreActivity::class.java)
|
||||||
|
push(intent)
|
||||||
|
}
|
||||||
// linkButton.setOnClickListener { linkDevice() }
|
// linkButton.setOnClickListener { linkDevice() }
|
||||||
if (TextSecurePreferences.getWasUnlinked(this)) {
|
if (TextSecurePreferences.getWasUnlinked(this)) {
|
||||||
Toast.makeText(this, R.string.activity_landing_device_unlinked_dialog_title, Toast.LENGTH_LONG).show()
|
Toast.makeText(this, R.string.activity_landing_device_unlinked_dialog_title, Toast.LENGTH_LONG).show()
|
||||||
|
@ -0,0 +1,71 @@
|
|||||||
|
package org.thoughtcrime.securesms.loki.api
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.database.Address
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.Data
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.Job
|
||||||
|
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||||
|
import org.thoughtcrime.securesms.jobs.BaseJob
|
||||||
|
import org.thoughtcrime.securesms.logging.Log
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.sms.MessageSender
|
||||||
|
import org.thoughtcrime.securesms.sms.OutgoingEndSessionMessage
|
||||||
|
import org.thoughtcrime.securesms.sms.OutgoingTextMessage
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class ResetThreadSessionJob private constructor(
|
||||||
|
parameters: Parameters,
|
||||||
|
private val address: Address,
|
||||||
|
private val threadId: Long)
|
||||||
|
: BaseJob(parameters) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KEY = "ResetThreadSessionJob"
|
||||||
|
const val DATA_KEY_ADDRESS = "address"
|
||||||
|
const val DATA_KEY_THREAD_ID = "thread_id"
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(address: Address, threadId: Long) : this(Parameters.Builder()
|
||||||
|
.addConstraint(NetworkConstraint.KEY)
|
||||||
|
.setQueue(KEY)
|
||||||
|
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||||
|
.setMaxAttempts(Parameters.UNLIMITED)
|
||||||
|
.build(),
|
||||||
|
address,
|
||||||
|
threadId)
|
||||||
|
|
||||||
|
override fun serialize(): Data {
|
||||||
|
return Data.Builder()
|
||||||
|
.putParcelable(DATA_KEY_ADDRESS, address)
|
||||||
|
.putLong(DATA_KEY_THREAD_ID, threadId)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFactoryKey(): String { return KEY }
|
||||||
|
|
||||||
|
public override fun onRun() {
|
||||||
|
val recipient = Recipient.from(context, address, false)
|
||||||
|
|
||||||
|
// Only reset sessions for private chats.
|
||||||
|
if (recipient.isGroupRecipient) return
|
||||||
|
|
||||||
|
Log.v(KEY, "Resetting session for thread: \"$threadId\", recipient: \"${address.serialize()}\"")
|
||||||
|
|
||||||
|
val message = OutgoingEndSessionMessage(OutgoingTextMessage(recipient, "TERMINATE", 0, -1))
|
||||||
|
MessageSender.send(context, message, threadId, false, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
public override fun onShouldRetry(e: Exception): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCanceled() { }
|
||||||
|
|
||||||
|
class Factory : Job.Factory<ResetThreadSessionJob> {
|
||||||
|
|
||||||
|
override fun create(parameters: Parameters, data: Data): ResetThreadSessionJob {
|
||||||
|
val address = data.getParcelable(DATA_KEY_ADDRESS, Address.CREATOR)
|
||||||
|
val threadId = data.getLong(DATA_KEY_THREAD_ID)
|
||||||
|
return ResetThreadSessionJob(parameters, address, threadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -71,7 +71,7 @@ class LokiAPIDatabase(context: Context, helper: SQLCipherOpenHelper) : Database(
|
|||||||
private val openGroupPublicKeyTable = "open_group_public_keys"
|
private val openGroupPublicKeyTable = "open_group_public_keys"
|
||||||
@JvmStatic val createOpenGroupPublicKeyTableCommand = "CREATE TABLE $openGroupPublicKeyTable ($server STRING PRIMARY KEY, $publicKey INTEGER DEFAULT 0);"
|
@JvmStatic val createOpenGroupPublicKeyTableCommand = "CREATE TABLE $openGroupPublicKeyTable ($server STRING PRIMARY KEY, $publicKey INTEGER DEFAULT 0);"
|
||||||
// Open group profile picture cache
|
// Open group profile picture cache
|
||||||
private val openGroupProfilePictureTable = "open_group_avatar_cache"
|
public val openGroupProfilePictureTable = "open_group_avatar_cache"
|
||||||
private val openGroupProfilePicture = "open_group_avatar"
|
private val openGroupProfilePicture = "open_group_avatar"
|
||||||
@JvmStatic val createOpenGroupProfilePictureTableCommand = "CREATE TABLE $openGroupProfilePictureTable ($publicChatID STRING PRIMARY KEY, $openGroupProfilePicture TEXT NULLABLE DEFAULT NULL);"
|
@JvmStatic val createOpenGroupProfilePictureTableCommand = "CREATE TABLE $openGroupProfilePictureTable ($publicChatID STRING PRIMARY KEY, $openGroupProfilePicture TEXT NULLABLE DEFAULT NULL);"
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ class LokiBackupFilesDatabase(context: Context, databaseHelper: SQLCipherOpenHel
|
|||||||
: Database(context, databaseHelper) {
|
: Database(context, databaseHelper) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TABLE_NAME = "backup_files"
|
public const val TABLE_NAME = "backup_files"
|
||||||
private const val COLUMN_ID = "_id"
|
private const val COLUMN_ID = "_id"
|
||||||
private const val COLUMN_URI = "uri"
|
private const val COLUMN_URI = "uri"
|
||||||
private const val COLUMN_FILE_SIZE = "file_size"
|
private const val COLUMN_FILE_SIZE = "file_size"
|
||||||
|
@ -42,7 +42,6 @@ fun View.fadeIn(duration: Long = 150) {
|
|||||||
|
|
||||||
fun View.fadeOut(duration: Long = 150) {
|
fun View.fadeOut(duration: Long = 150) {
|
||||||
animate().setDuration(duration).alpha(0.0f).setListener(object : AnimatorListenerAdapter() {
|
animate().setDuration(duration).alpha(0.0f).setListener(object : AnimatorListenerAdapter() {
|
||||||
|
|
||||||
override fun onAnimationEnd(animation: Animator?) {
|
override fun onAnimationEnd(animation: Animator?) {
|
||||||
super.onAnimationEnd(animation)
|
super.onAnimationEnd(animation)
|
||||||
visibility = View.GONE
|
visibility = View.GONE
|
||||||
|
@ -6,6 +6,7 @@ import android.content.DialogInterface;
|
|||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
@ -18,7 +19,7 @@ import org.greenrobot.eventbus.Subscribe;
|
|||||||
import org.greenrobot.eventbus.ThreadMode;
|
import org.greenrobot.eventbus.ThreadMode;
|
||||||
import org.thoughtcrime.securesms.ApplicationContext;
|
import org.thoughtcrime.securesms.ApplicationContext;
|
||||||
import org.thoughtcrime.securesms.backup.BackupDialog;
|
import org.thoughtcrime.securesms.backup.BackupDialog;
|
||||||
import org.thoughtcrime.securesms.backup.FullBackupBase.BackupEvent;
|
import org.thoughtcrime.securesms.backup.BackupEvent;
|
||||||
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat;
|
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat;
|
||||||
import org.thoughtcrime.securesms.jobs.LocalBackupJob;
|
import org.thoughtcrime.securesms.jobs.LocalBackupJob;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
@ -104,7 +105,7 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
|
|||||||
|
|
||||||
@Subscribe(threadMode = ThreadMode.MAIN)
|
@Subscribe(threadMode = ThreadMode.MAIN)
|
||||||
public void onEvent(BackupEvent event) {
|
public void onEvent(BackupEvent event) {
|
||||||
ProgressPreference preference = (ProgressPreference)findPreference(TextSecurePreferences.BACKUP_NOW);
|
ProgressPreference preference = findPreference(TextSecurePreferences.BACKUP_NOW);
|
||||||
|
|
||||||
if (event.getType() == BackupEvent.Type.PROGRESS) {
|
if (event.getType() == BackupEvent.Type.PROGRESS) {
|
||||||
preference.setEnabled(false);
|
preference.setEnabled(false);
|
||||||
@ -114,12 +115,21 @@ public class ChatsPreferenceFragment extends ListSummaryPreferenceFragment {
|
|||||||
preference.setEnabled(true);
|
preference.setEnabled(true);
|
||||||
preference.setProgressVisible(false);
|
preference.setProgressVisible(false);
|
||||||
setBackupSummary();
|
setBackupSummary();
|
||||||
|
|
||||||
|
if (event.getException() != null) {
|
||||||
|
Toast.makeText(
|
||||||
|
getActivity(),
|
||||||
|
getString(R.string.preferences_chats__backup_export_error),
|
||||||
|
Toast.LENGTH_LONG)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setBackupSummary() {
|
private void setBackupSummary() {
|
||||||
findPreference(TextSecurePreferences.BACKUP_NOW)
|
findPreference(TextSecurePreferences.BACKUP_NOW)
|
||||||
.setSummary(String.format(getString(R.string.ChatsPreferenceFragment_last_backup_s), BackupUtil.getLastBackupTimeString(getContext(), Locale.getDefault())));
|
.setSummary(String.format(getString(R.string.ChatsPreferenceFragment_last_backup_s),
|
||||||
|
BackupUtil.getLastBackupTimeString(getContext(), Locale.getDefault())));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setMediaDownloadSummaries() {
|
private void setMediaDownloadSummaries() {
|
||||||
|
@ -13,6 +13,8 @@ import androidx.annotation.WorkerThread
|
|||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import network.loki.messenger.R
|
import network.loki.messenger.R
|
||||||
|
import org.greenrobot.eventbus.EventBus
|
||||||
|
import org.thoughtcrime.securesms.backup.BackupEvent
|
||||||
import org.thoughtcrime.securesms.backup.BackupPassphrase
|
import org.thoughtcrime.securesms.backup.BackupPassphrase
|
||||||
import org.thoughtcrime.securesms.backup.FullBackupExporter
|
import org.thoughtcrime.securesms.backup.FullBackupExporter
|
||||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
|
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
|
||||||
@ -21,6 +23,8 @@ import org.thoughtcrime.securesms.loki.database.BackupFileRecord
|
|||||||
import org.thoughtcrime.securesms.service.LocalBackupListener
|
import org.thoughtcrime.securesms.service.LocalBackupListener
|
||||||
import org.whispersystems.libsignal.util.ByteUtil
|
import org.whispersystems.libsignal.util.ByteUtil
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.NoSuchAlgorithmException
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@ -28,6 +32,8 @@ import kotlin.jvm.Throws
|
|||||||
|
|
||||||
object BackupUtil {
|
object BackupUtil {
|
||||||
private const val TAG = "BackupUtil"
|
private const val TAG = "BackupUtil"
|
||||||
|
const val BACKUP_FILE_MIME_TYPE = "application/session-backup"
|
||||||
|
const val BACKUP_PASSPHRASE_LENGTH = 30
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set app-wide configuration to enable the backups and schedule them.
|
* Set app-wide configuration to enable the backups and schedule them.
|
||||||
@ -83,7 +89,7 @@ object BackupUtil {
|
|||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun generateBackupPassphrase(): Array<String> {
|
fun generateBackupPassphrase(): Array<String> {
|
||||||
val random = ByteArray(30).also { SecureRandom().nextBytes(it) }
|
val random = ByteArray(BACKUP_PASSPHRASE_LENGTH).also { SecureRandom().nextBytes(it) }
|
||||||
return Array(6) {i ->
|
return Array(6) {i ->
|
||||||
String.format("%05d", ByteUtil.byteArray5ToLong(random, i * 5) % 100000)
|
String.format("%05d", ByteUtil.byteArray5ToLong(random, i * 5) % 100000)
|
||||||
}
|
}
|
||||||
@ -151,7 +157,7 @@ object BackupUtil {
|
|||||||
val fileUri = DocumentsContract.createDocument(
|
val fileUri = DocumentsContract.createDocument(
|
||||||
context.contentResolver,
|
context.contentResolver,
|
||||||
DocumentFile.fromTreeUri(context, dirUri)!!.uri,
|
DocumentFile.fromTreeUri(context, dirUri)!!.uri,
|
||||||
"application/x-binary",
|
BACKUP_FILE_MIME_TYPE,
|
||||||
fileName)
|
fileName)
|
||||||
|
|
||||||
if (fileUri == null) {
|
if (fileUri == null) {
|
||||||
@ -159,17 +165,23 @@ object BackupUtil {
|
|||||||
throw IOException("Cannot create writable file in the dir $dirUri")
|
throw IOException("Cannot create writable file in the dir $dirUri")
|
||||||
}
|
}
|
||||||
|
|
||||||
FullBackupExporter.export(context,
|
try {
|
||||||
AttachmentSecretProvider.getInstance(context).orCreateAttachmentSecret,
|
FullBackupExporter.export(context,
|
||||||
DatabaseFactory.getBackupDatabase(context),
|
AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
|
||||||
fileUri,
|
DatabaseFactory.getBackupDatabase(context),
|
||||||
backupPassword)
|
fileUri,
|
||||||
|
backupPassword)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Delete the backup file on any error.
|
||||||
|
DocumentsContract.deleteDocument(context.contentResolver, fileUri)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
//TODO Use real file size.
|
//TODO Use real file size.
|
||||||
val record = DatabaseFactory.getLokiBackupFilesDatabase(context)
|
val record = DatabaseFactory.getLokiBackupFilesDatabase(context)
|
||||||
.insertBackupFile(BackupFileRecord(fileUri, -1, date))
|
.insertBackupFile(BackupFileRecord(fileUri, -1, date))
|
||||||
|
|
||||||
Log.v(TAG, "Backup file was created: $fileUri")
|
Log.v(TAG, "A backup file was created: $fileUri")
|
||||||
|
|
||||||
return record
|
return record
|
||||||
}
|
}
|
||||||
@ -197,6 +209,25 @@ object BackupUtil {
|
|||||||
Log.v(TAG, "Backup file was deleted: ${record.uri}")
|
Log.v(TAG, "Backup file was deleted: ${record.uri}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun computeBackupKey(passphrase: String, salt: ByteArray?): ByteArray {
|
||||||
|
return try {
|
||||||
|
EventBus.getDefault().post(BackupEvent.createProgress(0))
|
||||||
|
val digest = MessageDigest.getInstance("SHA-512")
|
||||||
|
val input = passphrase.replace(" ", "").toByteArray()
|
||||||
|
var hash: ByteArray = input
|
||||||
|
if (salt != null) digest.update(salt)
|
||||||
|
for (i in 0..249999) {
|
||||||
|
if (i % 1000 == 0) EventBus.getDefault().post(BackupEvent.createProgress(0))
|
||||||
|
digest.update(hash)
|
||||||
|
hash = digest.digest(input)
|
||||||
|
}
|
||||||
|
ByteUtil.trim(hash, 32)
|
||||||
|
} catch (e: NoSuchAlgorithmException) {
|
||||||
|
throw AssertionError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -13,6 +13,7 @@ import androidx.annotation.Nullable;
|
|||||||
import androidx.core.app.NotificationCompat;
|
import androidx.core.app.NotificationCompat;
|
||||||
|
|
||||||
import org.greenrobot.eventbus.EventBus;
|
import org.greenrobot.eventbus.EventBus;
|
||||||
|
import org.thoughtcrime.securesms.backup.BackupProtos;
|
||||||
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintObserver;
|
import org.thoughtcrime.securesms.jobmanager.impl.SqlCipherMigrationConstraintObserver;
|
||||||
import org.thoughtcrime.securesms.lock.RegistrationLockReminders;
|
import org.thoughtcrime.securesms.lock.RegistrationLockReminders;
|
||||||
import org.thoughtcrime.securesms.logging.Log;
|
import org.thoughtcrime.securesms.logging.Log;
|
||||||
@ -24,10 +25,15 @@ import java.security.SecureRandom;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import network.loki.messenger.R;
|
import network.loki.messenger.R;
|
||||||
|
|
||||||
|
import static org.thoughtcrime.securesms.backup.FullBackupImporter.PREF_PREFIX_TYPE_BOOLEAN;
|
||||||
|
import static org.thoughtcrime.securesms.backup.FullBackupImporter.PREF_PREFIX_TYPE_INT;
|
||||||
|
|
||||||
public class TextSecurePreferences {
|
public class TextSecurePreferences {
|
||||||
|
|
||||||
private static final String TAG = TextSecurePreferences.class.getSimpleName();
|
private static final String TAG = TextSecurePreferences.class.getSimpleName();
|
||||||
@ -141,7 +147,7 @@ public class TextSecurePreferences {
|
|||||||
private static final String ACTIVE_SIGNED_PRE_KEY_ID = "pref_active_signed_pre_key_id";
|
private static final String ACTIVE_SIGNED_PRE_KEY_ID = "pref_active_signed_pre_key_id";
|
||||||
private static final String NEXT_SIGNED_PRE_KEY_ID = "pref_next_signed_pre_key_id";
|
private static final String NEXT_SIGNED_PRE_KEY_ID = "pref_next_signed_pre_key_id";
|
||||||
|
|
||||||
public static final String BACKUP_ENABLED = "pref_backup_enabled_v2";
|
public static final String BACKUP_ENABLED = "pref_backup_enabled_v3";
|
||||||
private static final String BACKUP_PASSPHRASE = "pref_backup_passphrase";
|
private static final String BACKUP_PASSPHRASE = "pref_backup_passphrase";
|
||||||
private static final String ENCRYPTED_BACKUP_PASSPHRASE = "pref_encrypted_backup_passphrase";
|
private static final String ENCRYPTED_BACKUP_PASSPHRASE = "pref_encrypted_backup_passphrase";
|
||||||
private static final String BACKUP_TIME = "pref_backup_next_time";
|
private static final String BACKUP_TIME = "pref_backup_next_time";
|
||||||
@ -1334,4 +1340,92 @@ public class TextSecurePreferences {
|
|||||||
setBooleanPreference(context, "has_seen_light_theme_intro_sheet", true);
|
setBooleanPreference(context, "has_seen_light_theme_intro_sheet", true);
|
||||||
}
|
}
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
// region Backup related
|
||||||
|
public static List<BackupProtos.SharedPreference> getBackupRecords(@NonNull Context context) {
|
||||||
|
final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
|
|
||||||
|
final String prefsFileName;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
prefsFileName = PreferenceManager.getDefaultSharedPreferencesName(context);
|
||||||
|
} else {
|
||||||
|
prefsFileName = context.getPackageName() + "_preferences";
|
||||||
|
}
|
||||||
|
|
||||||
|
final LinkedList<BackupProtos.SharedPreference> prefList = new LinkedList<>();
|
||||||
|
addBackupEntryInt (prefList, preferences, prefsFileName, LOCAL_REGISTRATION_ID_PREF);
|
||||||
|
addBackupEntryString (prefList, preferences, prefsFileName, LOCAL_NUMBER_PREF);
|
||||||
|
addBackupEntryString (prefList, preferences, prefsFileName, PROFILE_NAME_PREF);
|
||||||
|
addBackupEntryString (prefList, preferences, prefsFileName, PROFILE_AVATAR_URL_PREF);
|
||||||
|
addBackupEntryInt (prefList, preferences, prefsFileName, PROFILE_AVATAR_ID_PREF);
|
||||||
|
addBackupEntryString (prefList, preferences, prefsFileName, PROFILE_KEY_PREF);
|
||||||
|
addBackupEntryBoolean(prefList, preferences, prefsFileName, IS_USING_FCM);
|
||||||
|
|
||||||
|
return prefList;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addBackupEntryString(
|
||||||
|
List<BackupProtos.SharedPreference> outPrefList,
|
||||||
|
SharedPreferences prefs,
|
||||||
|
String prefFileName,
|
||||||
|
String prefKey) {
|
||||||
|
String value = prefs.getString(prefKey, null);
|
||||||
|
if (value == null) {
|
||||||
|
logBackupEntry(prefKey, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||||
|
.setFile(prefFileName)
|
||||||
|
.setKey(prefKey)
|
||||||
|
.setValue(value)
|
||||||
|
.build());
|
||||||
|
logBackupEntry(prefKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addBackupEntryInt(
|
||||||
|
List<BackupProtos.SharedPreference> outPrefList,
|
||||||
|
SharedPreferences prefs,
|
||||||
|
String prefFileName,
|
||||||
|
String prefKey) {
|
||||||
|
int value = prefs.getInt(prefKey, -1);
|
||||||
|
if (value == -1) {
|
||||||
|
logBackupEntry(prefKey, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||||
|
.setFile(prefFileName)
|
||||||
|
.setKey(PREF_PREFIX_TYPE_INT + prefKey) // The prefix denotes the type of the preference.
|
||||||
|
.setValue(String.valueOf(value))
|
||||||
|
.build());
|
||||||
|
logBackupEntry(prefKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addBackupEntryBoolean(
|
||||||
|
List<BackupProtos.SharedPreference> outPrefList,
|
||||||
|
SharedPreferences prefs,
|
||||||
|
String prefFileName,
|
||||||
|
String prefKey) {
|
||||||
|
if (!prefs.contains(prefKey)) {
|
||||||
|
logBackupEntry(prefKey, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
outPrefList.add(BackupProtos.SharedPreference.newBuilder()
|
||||||
|
.setFile(prefFileName)
|
||||||
|
.setKey(PREF_PREFIX_TYPE_BOOLEAN + prefKey) // The prefix denotes the type of the preference.
|
||||||
|
.setValue(String.valueOf(prefs.getBoolean(prefKey, false)))
|
||||||
|
.build());
|
||||||
|
logBackupEntry(prefKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void logBackupEntry(String prefName, boolean wasIncluded) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("Backup preference ");
|
||||||
|
sb.append(wasIncluded ? "+ " : "- ");
|
||||||
|
sb.append('\"').append(prefName).append("\" ");
|
||||||
|
if (!wasIncluded) {
|
||||||
|
sb.append("(is empty and not included)");
|
||||||
|
}
|
||||||
|
Log.d(TAG, sb.toString());
|
||||||
|
}
|
||||||
|
// endregion
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user