diff --git a/app/build.gradle b/app/build.gradle index bfb7221f..ee0b33fe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -68,6 +68,7 @@ android { packagingOptions { exclude 'META-INF/LICENSE*' exclude 'META-INF/NOTICE.txt' + exclude 'META-INF/DEPENDENCIES' merge 'reference.conf' } externalNativeBuild { @@ -92,14 +93,26 @@ dependencies { exclude group: 'com.android.support', module: 'support-annotations' }) implementation 'com.google.android.material:material:1.0.0' - implementation 'androidx.preference:preference:1.0.0' + implementation 'androidx.preference:preference:1.1.0' implementation 'androidx.legacy:legacy-preference-v14:1.0.0' - implementation 'androidx.media:media:1.0.1' + implementation 'androidx.media:media:1.1.0' implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'com.google.code.findbugs:jsr305:3.0.2' - implementation 'com.google.android.gms:play-services-drive:17.0.0' - implementation 'com.google.android.gms:play-services-auth:17.0.0' + + // gdrive api rest v3 + google auth + def google_drive_rest_version = "v3-rev173-1.25.0" + def google_play_auth_version = "17.0.0" + def google_api_client_version = "1.26.0" + implementation "com.google.apis:google-api-services-drive:$google_drive_rest_version" + implementation("com.google.android.gms:play-services-auth:$google_play_auth_version") { + exclude group: 'org.apache.httpcomponents' + } + implementation "com.google.http-client:google-http-client-gson:$google_api_client_version" + implementation("com.google.api-client:google-api-client-android:$google_api_client_version") { + exclude group: 'org.apache.httpcomponents' + } + implementation('androidx.work:work-runtime:2.0.1') { exclude group: 'com.google.guava', module: 'listenablefuture' } diff --git a/app/src/androidTest/java/fr/acinq/eclair/crypto/BackupEncryptionTest.java b/app/src/androidTest/java/fr/acinq/eclair/crypto/BackupEncryptionTest.java index 9ec0065c..929212d5 100644 --- a/app/src/androidTest/java/fr/acinq/eclair/crypto/BackupEncryptionTest.java +++ b/app/src/androidTest/java/fr/acinq/eclair/crypto/BackupEncryptionTest.java @@ -17,20 +17,23 @@ package fr.acinq.eclair.crypto; import com.tozny.crypto.android.AesCbcWithIntegrity; -import fr.acinq.bitcoin.DeterministicWallet; -import fr.acinq.eclair.wallet.utils.EncryptedBackup; -import fr.acinq.eclair.wallet.utils.EncryptedData; + import org.junit.Assert; import org.junit.Test; -import scodec.bits.ByteVector; +import java.io.IOException; import java.security.GeneralSecurityException; import java.security.SecureRandom; +import fr.acinq.bitcoin.DeterministicWallet; +import fr.acinq.eclair.wallet.utils.EncryptedBackup; +import fr.acinq.eclair.wallet.utils.EncryptedData; +import scodec.bits.ByteVector; + public class BackupEncryptionTest { @Test - public void encryptWithSeed_v1() throws GeneralSecurityException { + public void encryptWithSeed_v1() throws GeneralSecurityException, IOException { // create a master key from a random seed byte[] seed = new byte[16]; @@ -52,9 +55,7 @@ public void encryptWithSeed_v1() throws GeneralSecurityException { Assert.assertTrue(AesCbcWithIntegrity.constantTimeEq(plaintext, decrypted)); } - @Test - public void encryptWithSeed_v2() throws GeneralSecurityException { - + private AesCbcWithIntegrity.SecretKeys getSecretKeyV2() { // create a master key from a random seed byte[] seed = new byte[16]; new SecureRandom().nextBytes(seed); @@ -62,17 +63,38 @@ public void encryptWithSeed_v2() throws GeneralSecurityException { // derive a hardened key from xpriv // hardened means that, even if the key is compromised, it is not possible to find the parent key - final AesCbcWithIntegrity.SecretKeys key = EncryptedData.secretKeyFromBinaryKey(EncryptedBackup.generateBackupKey_v2(xpriv)); + return EncryptedData.secretKeyFromBinaryKey(EncryptedBackup.generateBackupKey_v2(xpriv)); + } + + @Test + public void encryptWithSeed_v2() throws GeneralSecurityException, IOException { + final AesCbcWithIntegrity.SecretKeys key = getSecretKeyV2(); // data to encrypt byte[] plaintext = new byte[300]; new SecureRandom().nextBytes(plaintext); // apply encryption - EncryptedBackup encrypted = EncryptedBackup.encrypt(plaintext, key, EncryptedBackup.BACKUP_VERSION_2); + final EncryptedBackup encrypted = EncryptedBackup.encrypt(plaintext, key, EncryptedBackup.BACKUP_VERSION_2); byte[] decrypted = encrypted.decrypt(key); Assert.assertTrue(AesCbcWithIntegrity.constantTimeEq(plaintext, decrypted)); + + // let's also test that we can still read a version 3 encrypted backup (that is: data is first compressed, then encrypted) + final EncryptedBackup compressedAndEncrypted = EncryptedBackup.encrypt(decrypted, key, EncryptedBackup.BACKUP_VERSION_3); + byte[] decryptedAndUncompressed = compressedAndEncrypted.decrypt(key); + + Assert.assertTrue(AesCbcWithIntegrity.constantTimeEq(plaintext, decryptedAndUncompressed)); + } + + @Test(expected = GeneralSecurityException.class) + public void wrongKeyFails_v2() throws GeneralSecurityException, IOException { + final AesCbcWithIntegrity.SecretKeys goodKey = getSecretKeyV2(); + final AesCbcWithIntegrity.SecretKeys badKey = getSecretKeyV2(); + byte[] plaintext = new byte[300]; + new SecureRandom().nextBytes(plaintext); + final EncryptedBackup encrypted = EncryptedBackup.encrypt(plaintext, goodKey, EncryptedBackup.BACKUP_VERSION_2); + encrypted.decrypt(badKey); } } diff --git a/app/src/main/java/fr/acinq/eclair/wallet/activities/ChannelsBackupBaseActivity.java b/app/src/main/java/fr/acinq/eclair/wallet/activities/ChannelsBackupBaseActivity.java index eb3acf28..085c4423 100644 --- a/app/src/main/java/fr/acinq/eclair/wallet/activities/ChannelsBackupBaseActivity.java +++ b/app/src/main/java/fr/acinq/eclair/wallet/activities/ChannelsBackupBaseActivity.java @@ -20,58 +20,54 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.os.Handler; + import androidx.annotation.CallSuper; import androidx.annotation.NonNull; import androidx.annotation.UiThread; import androidx.core.app.ActivityCompat; + import com.google.android.gms.auth.api.signin.GoogleSignIn; import com.google.android.gms.auth.api.signin.GoogleSignInAccount; import com.google.android.gms.auth.api.signin.GoogleSignInClient; -import com.google.android.gms.auth.api.signin.GoogleSignInOptions; -import com.google.android.gms.drive.*; -import com.google.android.gms.drive.query.*; import com.google.android.gms.tasks.Task; -import fr.acinq.eclair.wallet.models.BackupTypes; -import fr.acinq.eclair.wallet.services.BackupUtils; -import fr.acinq.eclair.wallet.utils.Constants; -import fr.acinq.eclair.wallet.utils.WalletUtils; +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.drive.Drive; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import scala.Option; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; +import fr.acinq.eclair.wallet.models.BackupTypes; +import fr.acinq.eclair.wallet.utils.BackupHelper; +import fr.acinq.eclair.wallet.utils.Constants; +import scala.Option; + public abstract class ChannelsBackupBaseActivity extends EclairActivity { - final static int ACCESS_REQUEST_PING_INTERVAL = 500; + final static int ACCESS_REQUEST_PING_INTERVAL = 1500; static final int GDRIVE_REQUEST_CODE_SIGN_IN = 0; private final Logger log = LoggerFactory.getLogger(ChannelsBackupBaseActivity.class); - protected Map> accessRequestsMap = new HashMap<>(); - /** - * Handles high-level drive functions like sync - */ - private DriveClient mDriveClient; /** - * Handle access to Drive resources/files. + * This maps monitors the access status for each backup sources requested by the user. Access is often + * granted asynchronously and may not be known. + * - If the value is None, the source's access status is pending. + * - If the value is Some(true), access is granted. + * - If the value is Some(false), access is denied. + * + * Value should never be null. */ - private DriveResourceClient mDriveResourceClient; + protected Map> accessRequestsMap = new HashMap<>(); /** - * Retrieve a backup file from Drive and returns a Task with its metadata. + * Drive service to manage files using the REST v3 api. */ - public static Task retrieveEclairBackupTask(final Task appFolderTask, - final DriveResourceClient driveResourceClient, - final String backupFileName) { - return appFolderTask.continueWithTask(appFolder -> { - // retrieve file(s) from drive - final SortOrder sortOrder = new SortOrder.Builder().addSortDescending(SortableField.MODIFIED_DATE).build(); - final Query query = new Query.Builder().addFilter(Filters.eq(SearchableField.TITLE, backupFileName)) - .setSortOrder(sortOrder).build(); - return driveResourceClient.queryChildren(appFolder.getResult(), query); - }); - } + protected Drive mDrive; protected void requestAccess(final boolean checkLocal, final boolean checkGdrive) { accessRequestsMap.clear(); @@ -84,20 +80,23 @@ protected void requestAccess(final boolean checkLocal, final boolean checkGdrive accessRequestsMap.put(BackupTypes.GDRIVE, Option.apply(null)); requestGDriveAccess(); } - new Handler().postDelayed(this::checkAccessRequestIsDone, ACCESS_REQUEST_PING_INTERVAL); + checkAccessRequestIsDone(); } else { applyAccessRequestDone(); } } + private Handler accessCheckHandler = new Handler(); + private void checkAccessRequestIsDone() { + accessCheckHandler.removeCallbacksAndMessages(null); if (accessRequestsMap.isEmpty()) { applyAccessRequestDone(); } else { if (!accessRequestsMap.containsValue(Option.apply(null))) { applyAccessRequestDone(); } else { - new Handler().postDelayed(this::checkAccessRequestIsDone, ACCESS_REQUEST_PING_INTERVAL); + accessCheckHandler.postDelayed(this::checkAccessRequestIsDone, ACCESS_REQUEST_PING_INTERVAL); } } } @@ -106,7 +105,7 @@ protected void applyAccessRequestDone() { } protected void requestLocalAccessOrApply() { - if (!BackupUtils.Local.hasLocalAccess(this)) { + if (!BackupHelper.Local.hasLocalAccess(this)) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, Constants.PERMISSION_EXTERNAL_STORAGE_REQUEST); } else { applyLocalAccessGranted(); @@ -114,8 +113,8 @@ protected void requestLocalAccessOrApply() { } protected void requestGDriveAccess() { - final GoogleSignInAccount signInAccount = BackupUtils.GoogleDrive.getSigninAccount(getApplicationContext()); - final GoogleSignInClient googleSignInClient = GoogleSignIn.getClient(this, getGoogleSigninOptions()); + final GoogleSignInAccount signInAccount = BackupHelper.GoogleDrive.getSigninAccount(getApplicationContext()); + final GoogleSignInClient googleSignInClient = GoogleSignIn.getClient(this, BackupHelper.GoogleDrive.getGoogleSigninOptions()); if (signInAccount == null) { startActivityForResult(googleSignInClient.getSignInIntent(), GDRIVE_REQUEST_CODE_SIGN_IN); } else { @@ -139,15 +138,6 @@ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permis } } - protected GoogleSignInOptions getGoogleSigninOptions() { - return (new GoogleSignInOptions.Builder()).requestId().requestEmail().requestScopes(Drive.SCOPE_FILE).requestScopes(Drive.SCOPE_APPFOLDER).build(); - } - - Task retrieveEclairBackupTask() { - final Task appFolderTask = getDriveResourceClient().getAppFolder(); - return retrieveEclairBackupTask(appFolderTask, getDriveResourceClient(), WalletUtils.getEclairBackupFileName(app.seedHash.get())); - } - @UiThread @CallSuper protected void applyLocalAccessDenied() { @@ -169,36 +159,7 @@ protected void applyGdriveAccessDenied() { @UiThread @CallSuper protected void applyGdriveAccessGranted(final GoogleSignInAccount signInAccount) { - mDriveClient = Drive.getDriveClient(getApplicationContext(), signInAccount); - mDriveResourceClient = Drive.getDriveResourceClient(getApplicationContext(), signInAccount); + mDrive = BackupHelper.GoogleDrive.getDriveServiceFromAccount(getApplicationContext(), signInAccount); accessRequestsMap.put(BackupTypes.GDRIVE, Option.apply(true)); } - - @Override - protected void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (requestCode == GDRIVE_REQUEST_CODE_SIGN_IN) { - if (resultCode != RESULT_OK) { - log.info("Google Drive sign-in failed with code {}"); - applyGdriveAccessDenied(); - return; - } - final Task getAccountTask = GoogleSignIn.getSignedInAccountFromIntent(data); - final GoogleSignInAccount signInAccount = getAccountTask.getResult(); - if (getAccountTask.isSuccessful() && signInAccount != null) { - applyGdriveAccessGranted(signInAccount); - } else { - log.info("Google Drive sign-in failed, could not get account"); - applyGdriveAccessDenied(); - } - } - } - - protected DriveResourceClient getDriveResourceClient() { - return mDriveResourceClient; - } - - protected DriveClient getDriveClient() { - return mDriveClient; - } } diff --git a/app/src/main/java/fr/acinq/eclair/wallet/activities/ChannelsBackupSettingsActivity.java b/app/src/main/java/fr/acinq/eclair/wallet/activities/ChannelsBackupSettingsActivity.java index c7699fd1..519e8e81 100644 --- a/app/src/main/java/fr/acinq/eclair/wallet/activities/ChannelsBackupSettingsActivity.java +++ b/app/src/main/java/fr/acinq/eclair/wallet/activities/ChannelsBackupSettingsActivity.java @@ -18,26 +18,38 @@ import android.annotation.SuppressLint; import android.app.Dialog; +import android.content.Intent; import android.os.Bundle; import android.view.MotionEvent; import android.view.View; +import android.widget.Toast; + import androidx.appcompat.widget.Toolbar; import androidx.databinding.DataBindingUtil; + +import com.google.android.gms.auth.UserRecoverableAuthException; import com.google.android.gms.auth.api.signin.GoogleSignIn; import com.google.android.gms.auth.api.signin.GoogleSignInAccount; import com.google.android.gms.common.api.ApiException; import com.google.android.gms.common.api.CommonStatusCodes; -import fr.acinq.eclair.channel.ChannelPersisted; -import fr.acinq.eclair.wallet.R; -import fr.acinq.eclair.wallet.databinding.ActivityChannelsBackupSettingsBinding; -import fr.acinq.eclair.wallet.services.BackupUtils; -import fr.acinq.eclair.wallet.utils.EclairException; -import fr.acinq.eclair.wallet.utils.WalletUtils; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.text.DateFormat; +import java.util.Date; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; + +import fr.acinq.eclair.channel.ChannelPersisted; +import fr.acinq.eclair.wallet.R; +import fr.acinq.eclair.wallet.databinding.ActivityChannelsBackupSettingsBinding; +import fr.acinq.eclair.wallet.utils.BackupHelper; +import fr.acinq.eclair.wallet.utils.EclairException; +import fr.acinq.eclair.wallet.utils.WalletUtils; public class ChannelsBackupSettingsActivity extends ChannelsBackupBaseActivity { @@ -55,7 +67,7 @@ protected void onCreate(Bundle savedInstanceState) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); gdriveBackupDetailsDialog = getCustomDialog(R.string.backup_about).setPositiveButton(R.string.btn_ok, null).create(); - mBinding.setGoogleDriveAvailable(BackupUtils.GoogleDrive.isGDriveAvailable(getApplicationContext())); + mBinding.setGoogleDriveAvailable(BackupHelper.GoogleDrive.isGDriveAvailable(getApplicationContext())); mBinding.requestLocalAccessSwitch.setOnTouchListener((v, event) -> { if (event.getAction() == MotionEvent.ACTION_DOWN) { @@ -69,14 +81,13 @@ protected void onCreate(Bundle savedInstanceState) { mBinding.setRequestingGDriveAccess(true); mBinding.gdriveBackupStatus.setVisibility(View.GONE); if (mBinding.requestGdriveAccessSwitch.isChecked()) { - log.info("revoking access to gdrive"); - GoogleSignIn.getClient(getApplicationContext(), getGoogleSigninOptions()).revokeAccess() + GoogleSignIn.getClient(getApplicationContext(), BackupHelper.GoogleDrive.getGoogleSigninOptions()).revokeAccess() .addOnCompleteListener(aVoid -> { applyGdriveAccessDenied(); mBinding.setRequestingGDriveAccess(false); }); } else { - requestGDriveAccess(); + Executors.newSingleThreadExecutor().execute(this::requestGDriveAccess); } } return true; // consumes touch event @@ -85,8 +96,8 @@ protected void onCreate(Bundle savedInstanceState) { } @Override - protected void onResume() { - super.onResume(); + protected void onStart() { + super.onStart(); if (checkInit()) { checkGDriveAccess(); checkLocalAccess(); @@ -94,7 +105,7 @@ protected void onResume() { } private void checkLocalAccess() { - if (app.seedHash != null && BackupUtils.Local.hasLocalAccess(getApplicationContext())) { + if (app.seedHash != null && BackupHelper.Local.hasLocalAccess(getApplicationContext())) { applyLocalAccessGranted(); } else { applyLocalAccessDenied(); @@ -106,7 +117,7 @@ private void checkGDriveAccess() { new Thread() { @Override public void run() { - final GoogleSignInAccount signInAccount = BackupUtils.GoogleDrive.getSigninAccount(getApplicationContext()); + final GoogleSignInAccount signInAccount = BackupHelper.GoogleDrive.getSigninAccount(getApplicationContext()); if (signInAccount != null) { runOnUiThread(() -> applyGdriveAccessGranted(signInAccount)); } else { @@ -118,40 +129,69 @@ public void run() { protected void applyGdriveAccessDenied() { super.applyGdriveAccessDenied(); - BackupUtils.GoogleDrive.disableGDriveBackup(getApplicationContext()); + BackupHelper.GoogleDrive.disableGDriveBackup(getApplicationContext()); mBinding.gdriveBackupStatus.setVisibility(View.GONE); mBinding.requestGdriveAccessSwitch.setChecked(false); mBinding.setRequestingGDriveAccess(false); } + @Override + protected void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == GDRIVE_REQUEST_CODE_SIGN_IN) { + handleGdriveSigninResult(data); + } + } + + private void handleGdriveSigninResult(final Intent data) { + try { + final GoogleSignInAccount account = GoogleSignIn.getSignedInAccountFromIntent(data).getResult(ApiException.class); + if (account == null) { + throw new RuntimeException("empty account"); + } + applyGdriveAccessGranted(account); + } catch (Exception e) { + log.error("Google Drive sign-in failed, could not get account: ", e); + Toast.makeText(this, "Sign-in failed.", Toast.LENGTH_SHORT).show(); + applyGdriveAccessDenied(); + } + } + protected void applyGdriveAccessGranted(final GoogleSignInAccount signInAccount) { super.applyGdriveAccessGranted(signInAccount); new Thread() { @Override public void run() { - if (app != null && !BackupUtils.GoogleDrive.isGDriveEnabled(getApplicationContext())) { + if (app != null && !BackupHelper.GoogleDrive.isGDriveEnabled(getApplicationContext())) { // access is explicitly granted from a revoked state app.system.eventStream().publish(ChannelPersisted.apply(null, null, null, null)); } - retrieveEclairBackupTask().addOnSuccessListener(metadataBuffer -> runOnUiThread(() -> { - mBinding.gdriveBackupStatus.setVisibility(View.VISIBLE); - if (metadataBuffer.getCount() == 0) { - mBinding.gdriveBackupStatus.setText(getString(R.string.backupsettings_drive_state_nobackup, signInAccount.getEmail())); - } else { - mBinding.gdriveBackupStatus.setText(getString(R.string.backupsettings_drive_state, signInAccount.getEmail(), DateFormat.getDateTimeInstance().format(metadataBuffer.get(0).getModifiedDate()))); - } - mBinding.requestGdriveAccessSwitch.setChecked(true); - mBinding.setRequestingGDriveAccess(false); - BackupUtils.GoogleDrive.enableGDriveBackup(getApplicationContext()); - })).addOnFailureListener(e -> { - log.info("could not get backup metadata with cause {}", e.getLocalizedMessage()); - if (e instanceof ApiException) { - if (((ApiException) e).getStatusCode() == CommonStatusCodes.SIGN_IN_REQUIRED) { - GoogleSignIn.getClient(getApplicationContext(), getGoogleSigninOptions()).revokeAccess(); + BackupHelper.GoogleDrive.listBackups(Executors.newSingleThreadExecutor(), mDrive, WalletUtils.getEclairBackupFileName(app.seedHash.get())) + .addOnSuccessListener(filesList -> runOnUiThread(() -> { + final com.google.api.services.drive.model.File backup = BackupHelper.GoogleDrive.filterBestBackup(filesList); + if (backup == null) { + mBinding.gdriveBackupStatus.setText(getString(R.string.backupsettings_drive_state_nobackup, signInAccount.getEmail())); + } else { + mBinding.gdriveBackupStatus.setText(getString(R.string.backupsettings_drive_state, signInAccount.getEmail(), + DateFormat.getDateTimeInstance().format(new Date(backup.getModifiedTime().getValue())))); + } + mBinding.gdriveBackupStatus.setVisibility(View.VISIBLE); + mBinding.requestGdriveAccessSwitch.setChecked(true); + mBinding.setRequestingGDriveAccess(false); + BackupHelper.GoogleDrive.enableGDriveBackup(getApplicationContext()); + })) + .addOnFailureListener(e -> { + log.error("could not retrieve best backup from gdrive: ", e); + if (e instanceof ApiException) { + if (((ApiException) e).getStatusCode() == CommonStatusCodes.SIGN_IN_REQUIRED) { + GoogleSignIn.getClient(getApplicationContext(), BackupHelper.GoogleDrive.getGoogleSigninOptions()).revokeAccess(); + } + } + if (e instanceof UserRecoverableAuthException) { + GoogleSignIn.getClient(getApplicationContext(), BackupHelper.GoogleDrive.getGoogleSigninOptions()).revokeAccess(); } - } - applyGdriveAccessDenied(); - }); + applyGdriveAccessDenied(); + }); } }.start(); } @@ -167,7 +207,7 @@ protected void applyLocalAccessDenied() { protected void applyLocalAccessGranted() { super.applyLocalAccessGranted(); try { - final File found = BackupUtils.Local.getBackupFile(WalletUtils.getEclairBackupFileName(app.seedHash.get())); + final File found = BackupHelper.Local.getBackupFile(WalletUtils.getEclairBackupFileName(app.seedHash.get())); if (found.exists()) { mBinding.localBackupStatus.setVisibility(View.VISIBLE); mBinding.localBackupStatus.setText(getString(R.string.backupsettings_local_status_result, DateFormat.getDateTimeInstance().format(found.lastModified()))); diff --git a/app/src/main/java/fr/acinq/eclair/wallet/activities/HomeActivity.java b/app/src/main/java/fr/acinq/eclair/wallet/activities/HomeActivity.java index 38c5188d..a9f32229 100644 --- a/app/src/main/java/fr/acinq/eclair/wallet/activities/HomeActivity.java +++ b/app/src/main/java/fr/acinq/eclair/wallet/activities/HomeActivity.java @@ -55,7 +55,7 @@ import fr.acinq.eclair.wallet.fragments.ChannelsListFragment; import fr.acinq.eclair.wallet.fragments.PaymentsListFragment; import fr.acinq.eclair.wallet.fragments.ReceivePaymentFragment; -import fr.acinq.eclair.wallet.services.BackupUtils; +import fr.acinq.eclair.wallet.utils.BackupHelper; import fr.acinq.eclair.wallet.services.CheckElectrumWorker; import fr.acinq.eclair.wallet.utils.Constants; import fr.acinq.eclair.wallet.utils.TechnicalHelper; @@ -374,13 +374,13 @@ public void onSharedPreferenceChanged(final SharedPreferences prefs, final Strin } private void refreshChannelsBackupWarning() { - final boolean isBackupEnabled = BackupUtils.GoogleDrive.isGDriveEnabled(getApplicationContext()); + final boolean isBackupEnabled = BackupHelper.GoogleDrive.isGDriveEnabled(getApplicationContext()); if (isBackupEnabled) { // check that we also have access - if (BackupUtils.GoogleDrive.getSigninAccount(getApplicationContext()) == null) { + if (BackupHelper.GoogleDrive.getSigninAccount(getApplicationContext()) == null) { mBinding.channelsBackupWarning.startAnimation(mBlinkingAnimation); mBinding.setChannelsBackupEnabled(false); - BackupUtils.GoogleDrive.disableGDriveBackup(getApplicationContext()); + BackupHelper.GoogleDrive.disableGDriveBackup(getApplicationContext()); } else { mBinding.channelsBackupWarning.clearAnimation(); mBinding.setChannelsBackupEnabled(true); diff --git a/app/src/main/java/fr/acinq/eclair/wallet/activities/RestoreChannelsBackupActivity.java b/app/src/main/java/fr/acinq/eclair/wallet/activities/RestoreChannelsBackupActivity.java index ea23eb10..1f663b34 100644 --- a/app/src/main/java/fr/acinq/eclair/wallet/activities/RestoreChannelsBackupActivity.java +++ b/app/src/main/java/fr/acinq/eclair/wallet/activities/RestoreChannelsBackupActivity.java @@ -16,47 +16,65 @@ package fr.acinq.eclair.wallet.activities; +import android.content.Intent; import android.os.Bundle; import android.os.Handler; import android.preference.PreferenceManager; import android.text.Html; import android.widget.Toast; + import androidx.annotation.UiThread; import androidx.annotation.WorkerThread; import androidx.databinding.DataBindingUtil; + +import com.google.android.gms.auth.GoogleAuthException; +import com.google.android.gms.auth.api.signin.GoogleSignIn; import com.google.android.gms.auth.api.signin.GoogleSignInAccount; -import com.google.android.gms.drive.DriveFile; -import com.google.android.gms.drive.Metadata; -import com.google.android.gms.drive.metadata.CustomPropertyKey; +import com.google.android.gms.common.api.ApiException; +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAuthIOException; +import com.google.api.client.util.DateTime; import com.google.common.collect.MapDifference; import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.common.io.Files; -import fr.acinq.bitcoin.ByteVector32; -import fr.acinq.eclair.channel.HasCommitments; -import fr.acinq.eclair.db.ChannelsDb; -import fr.acinq.eclair.db.sqlite.SqliteChannelsDb; -import fr.acinq.eclair.wallet.R; -import fr.acinq.eclair.wallet.databinding.ActivityRestoreChannelsBackupBinding; -import fr.acinq.eclair.wallet.models.BackupTypes; -import fr.acinq.eclair.wallet.services.BackupUtils; -import fr.acinq.eclair.wallet.utils.*; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import scala.Option; -import scala.collection.Iterator; -import scala.collection.Seq; -import javax.annotation.Nullable; import java.io.File; import java.io.IOException; -import java.io.InputStream; import java.security.GeneralSecurityException; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.text.DateFormat; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import javax.annotation.Nullable; + +import fr.acinq.bitcoin.ByteVector32; +import fr.acinq.eclair.channel.HasCommitments; +import fr.acinq.eclair.db.ChannelsDb; +import fr.acinq.eclair.db.sqlite.SqliteChannelsDb; +import fr.acinq.eclair.wallet.R; +import fr.acinq.eclair.wallet.databinding.ActivityRestoreChannelsBackupBinding; +import fr.acinq.eclair.wallet.models.BackupTypes; +import fr.acinq.eclair.wallet.utils.BackupHelper; +import fr.acinq.eclair.wallet.utils.Constants; +import fr.acinq.eclair.wallet.utils.EclairException; +import fr.acinq.eclair.wallet.utils.EncryptedBackup; +import fr.acinq.eclair.wallet.utils.WalletUtils; +import scala.Option; +import scala.collection.Iterator; +import scala.collection.Seq; public class RestoreChannelsBackupActivity extends ChannelsBackupBaseActivity { @@ -91,11 +109,11 @@ protected void onResume() { if (app == null || app.seedHash == null || app.seedHash.get() == null) { finish(); } else { - mBinding.requestLocalAccessCheckbox.setChecked(BackupUtils.Local.isExternalStorageWritable()); - mBinding.setExternalStorageAvailable(BackupUtils.Local.isExternalStorageWritable()); + mBinding.requestLocalAccessCheckbox.setChecked(BackupHelper.Local.isExternalStorageWritable()); + mBinding.setExternalStorageAvailable(BackupHelper.Local.isExternalStorageWritable()); - mBinding.requestGdriveAccessCheckbox.setChecked(BackupUtils.GoogleDrive.isGDriveAvailable(getApplicationContext())); - mBinding.setGdriveAvailable(BackupUtils.GoogleDrive.isGDriveAvailable(getApplicationContext())); + mBinding.requestGdriveAccessCheckbox.setChecked(BackupHelper.GoogleDrive.isGDriveAvailable(getApplicationContext())); + mBinding.setGdriveAvailable(BackupHelper.GoogleDrive.isGDriveAvailable(getApplicationContext())); mBinding.seedHash.setText(getString(R.string.restorechannels_hash, app.seedHash.get())); } @@ -125,6 +143,8 @@ public void run() { if (hasGdriveAccess) { mExpectedBackupsMap.put(BackupTypes.GDRIVE, null); scanGoogleDrive(); + } else { + log.info("no access to Google Drive, gdrive backups will not be scanned"); } runOnUiThread(() -> new Handler().postDelayed(() -> checkScanningDone(), SCAN_PING_INTERVAL)); } @@ -154,7 +174,7 @@ private void checkScanningDone() { origin = getString(R.string.restore_channels_origin_local); break; case GDRIVE: - final GoogleSignInAccount gdriveAccount = BackupUtils.GoogleDrive.getSigninAccount(getApplicationContext()); + final GoogleSignInAccount gdriveAccount = BackupHelper.GoogleDrive.getSigninAccount(getApplicationContext()); origin = getString(R.string.restore_channels_origin_gdrive, gdriveAccount != null && gdriveAccount.getAccount() != null ? gdriveAccount.getAccount().name : getString(R.string.unknown)); break; @@ -227,6 +247,28 @@ public static List>> sortBackupD return set; } + @Override + protected void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == GDRIVE_REQUEST_CODE_SIGN_IN) { + handleGdriveSigninResult(data); + } + } + + private void handleGdriveSigninResult(final Intent data) { + try { + final GoogleSignInAccount account = GoogleSignIn.getSignedInAccountFromIntent(data).getResult(ApiException.class); + if (account == null) { + throw new RuntimeException("empty account"); + } + applyGdriveAccessGranted(account); + } catch (Exception e) { + log.error("Google Drive sign-in failed, could not get account: ", e); + Toast.makeText(this, "Sign-in failed.", Toast.LENGTH_SHORT).show(); + applyGdriveAccessDenied(); + } + } + @Nullable public static BackupScanOk findBestBackup(Map> backupsSet) throws EclairException.UnreadableBackupException { BackupScanOk bestBackupYet = null; @@ -271,13 +313,12 @@ public static BackupScanOk findBestBackup(Map retrieveEclairBackupTask()) - .addOnSuccessListener(metadataBuffer -> { - if (metadataBuffer.getCount() > 0) { - final Metadata metadata = metadataBuffer.get(0); - final Date modifiedDate = metadata.getModifiedDate(); - final String remoteDeviceId = metadata.getCustomProperties().get(new CustomPropertyKey(Constants.BACKUP_META_DEVICE_ID, CustomPropertyKey.PUBLIC)); - final String deviceId = WalletUtils.getDeviceId(getApplicationContext()); - getDriveResourceClient().openFile(metadata.getDriveId().asDriveFile(), DriveFile.MODE_READ_ONLY) - .addOnSuccessListener(driveFileContents -> { + log.debug("starting to scan gdrive for backups"); + final Executor executor = Executors.newSingleThreadExecutor(); + // use legacy list method to also retrieve old backup that could have been left in the hidden app data folder. + BackupHelper.GoogleDrive.listBackupsLegacy(Executors.newSingleThreadExecutor(), mDrive, WalletUtils.getEclairBackupFileName(app.seedHash.get())) + .addOnSuccessListener(files -> { + log.info("found {} backup files on gdrive for this seed: {}", files.getFiles().size(), files); + final com.google.api.services.drive.model.File backup = BackupHelper.GoogleDrive.filterBestBackup(files); + if (backup == null) { + mExpectedBackupsMap.put(BackupTypes.GDRIVE, Option.apply(null)); + } else { + final DateTime modifiedDate = backup.getModifiedTime(); + log.info("best backup from gdrive is {}", backup); + final String currentDeviceId = WalletUtils.getDeviceId(getApplicationContext()); + final Map props = backup.getAppProperties(); + final String remoteDeviceId = props != null ? props.get(Constants.BACKUP_META_DEVICE_ID) : currentDeviceId; + BackupHelper.GoogleDrive.getFileContent(executor, mDrive, backup.getId()) + .addOnSuccessListener(content -> { try { - // read file content - final InputStream driveInputStream = driveFileContents.getInputStream(); - final byte[] content = new byte[driveInputStream.available()]; - driveInputStream.read(content); - // decrypt content - final BackupScanOk gdriveBackup = decryptFile(content, modifiedDate, BackupTypes.GDRIVE); - gdriveBackup.setIsFromDevice(remoteDeviceId == null || deviceId.equals(remoteDeviceId)); + final BackupScanOk gdriveBackup = decryptFile(content, new Date(modifiedDate.getValue()), BackupTypes.GDRIVE); + gdriveBackup.setIsFromDevice(remoteDeviceId == null || currentDeviceId.equals(remoteDeviceId)); mExpectedBackupsMap.put(BackupTypes.GDRIVE, Option.apply(gdriveBackup)); log.debug("successfully retrieved backup file from gdrive"); } catch (Throwable t) { log.error("could not read backup file from gdrive: ", t); mExpectedBackupsMap.put(BackupTypes.GDRIVE, Option.apply(new BackupScanFailure(t.getLocalizedMessage()))); } finally { - log.debug("finished scan gdrive"); - getDriveResourceClient().discardContents(driveFileContents); + log.debug("finished gdrive scan"); } + }) + .addOnFailureListener(e -> { + log.error("could not retrieve backup content from gdrive: ", e); + Toast.makeText(getApplicationContext(), R.string.restorechannels_gdrive_scan_error, Toast.LENGTH_LONG).show(); + mExpectedBackupsMap.put(BackupTypes.GDRIVE, Option.apply(null)); }); - } else { - log.info("no backup file found on gdrive for this seed"); - mExpectedBackupsMap.put(BackupTypes.GDRIVE, Option.apply(null)); } }) .addOnFailureListener(e -> { - log.error("could not retrieve data from gdrive: ", e); - mExpectedBackupsMap.put(BackupTypes.GDRIVE, Option.apply(null)); + log.error("could not retrieve backup files from gdrive: ", e); + if (e instanceof GoogleAuthIOException || e instanceof GoogleAuthException) { + mExpectedBackupsMap.clear(); + mBinding.setRestoreStep(Constants.RESTORE_BACKUP_REQUESTING_ACCESS); + requestAccess(mBinding.requestLocalAccessCheckbox.isChecked(), mBinding.requestGdriveAccessCheckbox.isChecked()); + } else { + Toast.makeText(getApplicationContext(), R.string.restorechannels_gdrive_scan_error, Toast.LENGTH_LONG).show(); + mExpectedBackupsMap.put(BackupTypes.GDRIVE, Option.apply(null)); + } }); } @WorkerThread private BackupScanOk decryptFile(final byte[] content, final Date modified, final BackupTypes type) throws IOException, GeneralSecurityException, SQLException, ClassNotFoundException { - log.debug("decrypting backup file from {}", type); // 1 - retrieve, decrypt and write backup file to datadir final EncryptedBackup encryptedContent = EncryptedBackup.read(content); - final byte[] decryptedContent = encryptedContent.decrypt(EncryptedData.secretKeyFromBinaryKey(EncryptedBackup.BACKUP_VERSION_1 == encryptedContent.getVersion() ? app.backupKey_v1.get() : app.backupKey_v2.get())); + final byte[] decryptedContent = encryptedContent.decrypt(EncryptedBackup.secretKeyFromBinaryKey(EncryptedBackup.BACKUP_VERSION_1 == encryptedContent.getVersion() ? app.backupKey_v1.get() : app.backupKey_v2.get())); + log.debug("successfully decrypted backup from {}", type); final File decryptedFile = new File(WalletUtils.getDatadir(getApplicationContext()), type.toString() + "-restore.sqlite.tmp"); Files.write(decryptedContent, decryptedFile); @@ -379,13 +430,13 @@ protected void applyAccessRequestDone() { @Override protected void applyGdriveAccessDenied() { super.applyGdriveAccessDenied(); - BackupUtils.GoogleDrive.disableGDriveBackup(getApplicationContext()); + BackupHelper.GoogleDrive.disableGDriveBackup(getApplicationContext()); } @Override protected void applyGdriveAccessGranted(GoogleSignInAccount signIn) { super.applyGdriveAccessGranted(signIn); - BackupUtils.GoogleDrive.enableGDriveBackup(getApplicationContext()); + BackupHelper.GoogleDrive.enableGDriveBackup(getApplicationContext()); } public interface BackupScanResult { diff --git a/app/src/main/java/fr/acinq/eclair/wallet/activities/SetupChannelsBackupActivity.java b/app/src/main/java/fr/acinq/eclair/wallet/activities/SetupChannelsBackupActivity.java index 4441305f..776d83fa 100644 --- a/app/src/main/java/fr/acinq/eclair/wallet/activities/SetupChannelsBackupActivity.java +++ b/app/src/main/java/fr/acinq/eclair/wallet/activities/SetupChannelsBackupActivity.java @@ -17,14 +17,20 @@ package fr.acinq.eclair.wallet.activities; import androidx.databinding.DataBindingUtil; + +import android.content.Intent; import android.os.Bundle; import android.view.View; import android.widget.Toast; + +import com.google.android.gms.auth.api.signin.GoogleSignIn; import com.google.android.gms.auth.api.signin.GoogleSignInAccount; +import com.google.android.gms.common.api.ApiException; + import fr.acinq.eclair.wallet.BuildConfig; import fr.acinq.eclair.wallet.R; import fr.acinq.eclair.wallet.databinding.ActivitySetupChannelsBackupBinding; -import fr.acinq.eclair.wallet.services.BackupUtils; +import fr.acinq.eclair.wallet.utils.BackupHelper; import fr.acinq.eclair.wallet.utils.Constants; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,7 +53,7 @@ protected void onCreate(Bundle savedInstanceState) { mBinding.setSetupBackupStep(Constants.SETUP_BACKUP_INIT); // only show gdrive box if necessary - if (!getIntent().getBooleanExtra(EXTRA_SETUP_IGNORE_GDRIVE_BACKUP, false) && BackupUtils.GoogleDrive.isGDriveAvailable(getApplicationContext())) { + if (!getIntent().getBooleanExtra(EXTRA_SETUP_IGNORE_GDRIVE_BACKUP, false) && BackupHelper.GoogleDrive.isGDriveAvailable(getApplicationContext())) { mBinding.requestGdriveAccessCheckbox.setVisibility(View.VISIBLE); } else { mBinding.requestGdriveAccessCheckbox.setEnabled(false); @@ -59,20 +65,20 @@ protected void onCreate(Bundle savedInstanceState) { @Override protected void applyGdriveAccessDenied() { super.applyGdriveAccessDenied(); - BackupUtils.GoogleDrive.disableGDriveBackup(getApplicationContext()); + BackupHelper.GoogleDrive.disableGDriveBackup(getApplicationContext()); mBinding.requestGdriveAccessCheckbox.setChecked(false); } @Override protected void applyGdriveAccessGranted(final GoogleSignInAccount signIn) { super.applyGdriveAccessGranted(signIn); - BackupUtils.GoogleDrive.enableGDriveBackup(getApplicationContext()); + BackupHelper.GoogleDrive.enableGDriveBackup(getApplicationContext()); mBinding.requestGdriveAccessCheckbox.setChecked(true); } @Override protected void applyAccessRequestDone() { - if (!BackupUtils.Local.hasLocalAccess(getApplicationContext())) { + if (!BackupHelper.Local.hasLocalAccess(getApplicationContext())) { log.info("access to local drive denied!"); mBinding.setSetupBackupStep(Constants.SETUP_BACKUP_INIT); Toast.makeText(this, getString(R.string.setupbackup_local_required), Toast.LENGTH_LONG).show(); @@ -81,4 +87,26 @@ protected void applyAccessRequestDone() { } } + @Override + protected void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == GDRIVE_REQUEST_CODE_SIGN_IN) { + handleGdriveSigninResult(data); + } + } + + private void handleGdriveSigninResult(final Intent data) { + try { + final GoogleSignInAccount account = GoogleSignIn.getSignedInAccountFromIntent(data).getResult(ApiException.class); + if (account == null) { + throw new RuntimeException("empty account"); + } + applyGdriveAccessGranted(account); + } catch (Exception e) { + log.error("Google Drive sign-in failed, could not get account: ", e); + Toast.makeText(this, "Sign-in failed.", Toast.LENGTH_SHORT).show(); + applyGdriveAccessDenied(); + } + } + } diff --git a/app/src/main/java/fr/acinq/eclair/wallet/activities/StartupActivity.java b/app/src/main/java/fr/acinq/eclair/wallet/activities/StartupActivity.java index dc1d5104..e24dd0d4 100644 --- a/app/src/main/java/fr/acinq/eclair/wallet/activities/StartupActivity.java +++ b/app/src/main/java/fr/acinq/eclair/wallet/activities/StartupActivity.java @@ -60,6 +60,7 @@ import fr.acinq.eclair.Setup; import fr.acinq.eclair.blockchain.electrum.ElectrumEclairWallet; import fr.acinq.eclair.channel.ChannelEvent; +import fr.acinq.eclair.channel.ChannelPersisted; import fr.acinq.eclair.crypto.LocalKeyManager; import fr.acinq.eclair.db.BackupEvent; import fr.acinq.eclair.payment.PaymentEvent; @@ -73,7 +74,8 @@ import fr.acinq.eclair.wallet.actors.RefreshScheduler; import fr.acinq.eclair.wallet.databinding.ActivityStartupBinding; import fr.acinq.eclair.wallet.fragments.PinDialog; -import fr.acinq.eclair.wallet.services.BackupUtils; +import fr.acinq.eclair.wallet.services.ChannelsBackupWorker; +import fr.acinq.eclair.wallet.utils.BackupHelper; import fr.acinq.eclair.wallet.services.CheckElectrumWorker; import fr.acinq.eclair.wallet.services.NetworkSyncWorker; import fr.acinq.eclair.wallet.utils.Constants; @@ -167,7 +169,17 @@ private void showError(final String message) { showError(message, false, false, false); } - private void goToHome() { + private void finishAndGoToHome(final SharedPreferences prefs) { + app.scheduleExchangeRatePoll(); + prefs.edit() + .putBoolean(Constants.SETTING_HAS_STARTED_ONCE, true) + .putLong(Constants.SETTING_LAST_SUCCESSFUL_BOOT_DATE, System.currentTimeMillis()) + .apply(); + NetworkSyncWorker.scheduleSync(); + CheckElectrumWorker.schedule(); + afterStartupMigration(prefs); + + // -- close current page and open HomeActivity finish(); final Intent originIntent = getIntent(); final Intent homeIntent = new Intent(getBaseContext(), HomeActivity.class); @@ -194,7 +206,7 @@ private void checkup() { return; } // check that external storage is available ; if not, print a warning - if (prefs.getBoolean(Constants.SETTING_HAS_STARTED_ONCE, false) && checkExternalStorageState && !BackupUtils.Local.isExternalStorageWritable()) { + if (prefs.getBoolean(Constants.SETTING_HAS_STARTED_ONCE, false) && checkExternalStorageState && !BackupHelper.Local.isExternalStorageWritable()) { getCustomDialog(getString(R.string.backup_external_storage_error)).setPositiveButton(R.string.btn_ok, (dialog, which) -> { checkExternalStorageState = false; // let the user start the app anyway checkup(); @@ -209,7 +221,7 @@ private void checkup() { private boolean checkAppVersion(final File datadir, final SharedPreferences prefs) { final int lastUsedVersion = prefs.getInt(Constants.SETTING_LAST_USED_VERSION, 0); final boolean startedOnce = prefs.getBoolean(Constants.SETTING_HAS_STARTED_ONCE, false); - // migration script only if app has already been started + // migration applies only if app has already been started if (lastUsedVersion > 0 && startedOnce) { if (lastUsedVersion <= 15 && "testnet".equals(BuildConfig.CHAIN)) { // version 16 breaks the application's data folder structure @@ -229,10 +241,22 @@ private boolean checkAppVersion(final File datadir, final SharedPreferences pref } } } - prefs.edit().putInt(Constants.SETTING_LAST_USED_VERSION, BuildConfig.VERSION_CODE).commit(); return true; } + private void afterStartupMigration(final SharedPreferences prefs) { + final int lastUsedVersion = prefs.getInt(Constants.SETTING_LAST_USED_VERSION, 0); + final boolean startedOnce = prefs.getBoolean(Constants.SETTING_HAS_STARTED_ONCE, false); + // migration applies only if app has already been started + if (lastUsedVersion > 0 && startedOnce) { + if (lastUsedVersion <= 48) { + // forces the app to push backup to the new gdrive public folder + app.system.eventStream().publish(ChannelPersisted.apply(null, null, null, null)); + } + } + prefs.edit().putInt(Constants.SETTING_LAST_USED_VERSION, BuildConfig.VERSION_CODE).apply(); + } + private void migrateTestnetSqlite(final File datadir) { final File eclairSqlite = new File(datadir, "eclair.sqlite"); final File testnetDir = new File(datadir, "testnet"); @@ -341,7 +365,7 @@ public void onPinCancel(PinDialog dialog) { } } else { // core is started, go to home and use it - goToHome(); + finishAndGoToHome(prefs); } } @@ -369,7 +393,7 @@ public void run() { return; } // stop if no access to local storage for local backup - if (!BackupUtils.Local.hasLocalAccess(getApplicationContext())) { + if (!BackupHelper.Local.hasLocalAccess(getApplicationContext())) { final Intent backupSetupIntent = new Intent(getBaseContext(), SetupChannelsBackupActivity.class); if (prefs.getBoolean(Constants.SETTING_HAS_STARTED_ONCE, false)) { backupSetupIntent.putExtra(SetupChannelsBackupActivity.EXTRA_SETUP_IGNORE_GDRIVE_BACKUP, true); @@ -411,14 +435,7 @@ public void processStartupFinish(StartupCompleteEvent event) { switch (event.status) { case StartupTask.SUCCESS: if (isAppReady()) { - app.scheduleExchangeRatePoll(); - prefs.edit() - .putBoolean(Constants.SETTING_HAS_STARTED_ONCE, true) - .putLong(Constants.SETTING_LAST_SUCCESSFUL_BOOT_DATE, System.currentTimeMillis()) - .apply(); - NetworkSyncWorker.scheduleSync(); - CheckElectrumWorker.schedule(); - goToHome(); + finishAndGoToHome(prefs); } else { // empty appkit, something went wrong. showError(getString(R.string.start_error_improper)); diff --git a/app/src/main/java/fr/acinq/eclair/wallet/services/BackupUtils.java b/app/src/main/java/fr/acinq/eclair/wallet/services/BackupUtils.java deleted file mode 100644 index ca3be79f..00000000 --- a/app/src/main/java/fr/acinq/eclair/wallet/services/BackupUtils.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright 2019 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.eclair.wallet.services; - -import android.Manifest; -import android.content.Context; -import android.content.pm.PackageManager; -import android.os.Environment; -import android.preference.PreferenceManager; -import androidx.core.content.ContextCompat; -import com.google.android.gms.auth.api.signin.GoogleSignIn; -import com.google.android.gms.auth.api.signin.GoogleSignInAccount; -import com.google.android.gms.common.ConnectionResult; -import com.google.android.gms.common.GoogleApiAvailability; -import com.google.android.gms.common.api.Scope; -import com.google.android.gms.drive.Drive; -import fr.acinq.eclair.wallet.BuildConfig; -import fr.acinq.eclair.wallet.utils.Constants; -import fr.acinq.eclair.wallet.utils.EclairException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; -import java.util.HashSet; -import java.util.Set; - -public interface BackupUtils { - - interface Local { - - static boolean isExternalStorageWritable() { - return Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()); - } - - static boolean hasLocalAccess(final Context context) { - return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; - } - - static File getBackupFile(final String backupFileName) throws EclairException.ExternalStorageUnavailableException { - if (!isExternalStorageWritable()) { - throw new EclairException.ExternalStorageUnavailableException(); - } - - final File storage = Environment.getExternalStorageDirectory(); - if (!storage.canWrite()) { - throw new EclairException.ExternalStorageUnavailableException(); - } - - final File publicDir = new File(storage, Constants.ECLAIR_BACKUP_DIR); - final File chainDir = new File(publicDir, BuildConfig.CHAIN); - final File backup = new File(chainDir, backupFileName); - - if (!backup.exists()) { - if (!chainDir.exists() && !chainDir.mkdirs()) { - throw new EclairException.ExternalStorageUnavailableException(); - } - } - - return backup; - } - } - - interface GoogleDrive { - - Logger log = LoggerFactory.getLogger(GoogleDrive.class); - - static boolean isGDriveAvailable(final Context context) { - final int connectionResult = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context); - if (connectionResult != ConnectionResult.SUCCESS) { - log.info("Google play services are not available (code {})", connectionResult); - return false; - } else { - return true; - } - } - - static GoogleSignInAccount getSigninAccount(final Context context) { - final Set requiredScopes = new HashSet<>(2); - requiredScopes.add(Drive.SCOPE_FILE); - requiredScopes.add(Drive.SCOPE_APPFOLDER); - final GoogleSignInAccount signInAccount = GoogleSignIn.getLastSignedInAccount(context); - if (signInAccount != null && signInAccount.getGrantedScopes().containsAll(requiredScopes)) { - return signInAccount; - } else { - return null; - } - } - - static void disableGDriveBackup(final Context context) { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putBoolean(Constants.SETTING_CHANNELS_BACKUP_GOOGLEDRIVE_ENABLED, false) - .apply(); - } - - static void enableGDriveBackup(final Context context) { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putBoolean(Constants.SETTING_CHANNELS_BACKUP_GOOGLEDRIVE_ENABLED, true) - .apply(); - } - - static boolean isGDriveEnabled(final Context context) { - return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(Constants.SETTING_CHANNELS_BACKUP_GOOGLEDRIVE_ENABLED, false); - } - } -} diff --git a/app/src/main/java/fr/acinq/eclair/wallet/services/ChannelsBackupWorker.java b/app/src/main/java/fr/acinq/eclair/wallet/services/ChannelsBackupWorker.java index 466d6ad7..17a2314a 100644 --- a/app/src/main/java/fr/acinq/eclair/wallet/services/ChannelsBackupWorker.java +++ b/app/src/main/java/fr/acinq/eclair/wallet/services/ChannelsBackupWorker.java @@ -17,35 +17,43 @@ package fr.acinq.eclair.wallet.services; import android.content.Context; + import androidx.annotation.NonNull; -import androidx.work.*; -import com.google.android.gms.drive.*; -import com.google.android.gms.drive.metadata.CustomPropertyKey; -import com.google.android.gms.tasks.Task; +import androidx.work.Data; +import androidx.work.ExistingWorkPolicy; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import com.google.android.gms.auth.GoogleAuthException; +import com.google.android.gms.auth.api.signin.GoogleSignInAccount; import com.google.android.gms.tasks.Tasks; -import com.google.common.io.ByteStreams; +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAuthIOException; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.FileList; import com.google.common.io.Files; import com.tozny.crypto.android.AesCbcWithIntegrity; -import fr.acinq.bitcoin.ByteVector32; -import fr.acinq.eclair.wallet.BuildConfig; -import fr.acinq.eclair.wallet.activities.ChannelsBackupBaseActivity; -import fr.acinq.eclair.wallet.utils.Constants; -import fr.acinq.eclair.wallet.utils.EncryptedBackup; -import fr.acinq.eclair.wallet.utils.EncryptedData; -import fr.acinq.eclair.wallet.utils.WalletUtils; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.spongycastle.util.encoders.Hex; -import scodec.bits.ByteVector; -import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; -import java.io.InputStream; import java.security.GeneralSecurityException; import java.util.Objects; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import fr.acinq.bitcoin.ByteVector32; +import fr.acinq.eclair.wallet.BuildConfig; +import fr.acinq.eclair.wallet.utils.BackupHelper; +import fr.acinq.eclair.wallet.utils.EncryptedBackup; +import fr.acinq.eclair.wallet.utils.EncryptedData; +import fr.acinq.eclair.wallet.utils.WalletUtils; +import scodec.bits.ByteVector; + /** * Saves the eclair.sqlite backup file to an external storage folder (root/Eclair Mobile) and/or to Google Drive, * depending on the user's preferences. Local backup is mandatory. @@ -87,7 +95,7 @@ public Result doWork() { final byte[] encryptedBackup = getEncryptedBackup(getApplicationContext(), sk); // 2 - save to drive - final boolean shouldBackupToDrive = BackupUtils.GoogleDrive.isGDriveAvailable(getApplicationContext()) && BackupUtils.GoogleDrive.getSigninAccount(getApplicationContext()) != null; + final boolean shouldBackupToDrive = BackupHelper.GoogleDrive.isGDriveAvailable(getApplicationContext()) && BackupHelper.GoogleDrive.getSigninAccount(getApplicationContext()) != null; boolean driveBackupSuccessful = true; if (shouldBackupToDrive) { driveBackupSuccessful = saveToGoogleDrive(getApplicationContext(), encryptedBackup, backupFileName); @@ -119,13 +127,13 @@ public Result doWork() { */ private byte[] getEncryptedBackup(final Context context, final AesCbcWithIntegrity.SecretKeys sk) throws IOException, GeneralSecurityException { final File eclairDBFile = WalletUtils.getEclairDBFileBak(context); - final EncryptedBackup backup = EncryptedBackup.encrypt(Files.toByteArray(eclairDBFile), sk, EncryptedBackup.BACKUP_VERSION_2); + final EncryptedBackup backup = EncryptedBackup.encrypt(Files.toByteArray(eclairDBFile), sk, EncryptedBackup.BACKUP_VERSION_3); return backup.write(); } private boolean saveToLocal(final byte[] encryptedBackup, final String backupFileName) { try { - final File backupFile = BackupUtils.Local.getBackupFile(backupFileName); + final File backupFile = BackupHelper.Local.getBackupFile(backupFileName); Files.write(encryptedBackup, backupFile); return true; } catch (Throwable t) { @@ -136,68 +144,40 @@ private boolean saveToLocal(final byte[] encryptedBackup, final String backupFil private boolean saveToGoogleDrive(final Context context, final byte[] encryptedBackup, final String backupFileName) { try { + final String deviceId = WalletUtils.getDeviceId(context); + final GoogleSignInAccount account = BackupHelper.GoogleDrive.getSigninAccount(context); + if (account == null) { + throw new GoogleAuthException(); + } + // 1 - retrieve existing backup so we know whether we have to create a new one, or update existing file - final DriveResourceClient driveResourceClient = Drive.getDriveResourceClient(context, Objects.requireNonNull(BackupUtils.GoogleDrive.getSigninAccount(context))); - final Task appFolderTask = driveResourceClient.getAppFolder(); - final Task metadataBufferTask = appFolderTask.continueWithTask(t -> - ChannelsBackupBaseActivity.retrieveEclairBackupTask(appFolderTask, driveResourceClient, backupFileName)); - final MetadataBuffer buffer = Tasks.await(metadataBufferTask); + final Drive drive = Objects.requireNonNull(BackupHelper.GoogleDrive.getDriveServiceFromAccount(context, account), "drive service must not be null"); + final FileList backups = Tasks.await(BackupHelper.GoogleDrive.listBackups(Executors.newSingleThreadExecutor(), drive, backupFileName)); + final com.google.api.services.drive.model.File backup = BackupHelper.GoogleDrive.filterBestBackup(backups); // 2 - create or update backup file - if (buffer.getCount() == 0) { - Tasks.await(createBackupOnDrive(encryptedBackup, driveResourceClient, backupFileName)); + if (backup == null) { + Tasks.await(BackupHelper.GoogleDrive.createBackup(Executors.newSingleThreadExecutor(), drive, backupFileName, encryptedBackup, deviceId)); + log.info("backup file successfully created on google drive"); } else { - Tasks.await(updateBackupOnDrive(encryptedBackup, driveResourceClient, buffer.get(0).getDriveId().asDriveFile())); + Tasks.await(BackupHelper.GoogleDrive.updateBackup(Executors.newSingleThreadExecutor(), drive, backup.getId(), encryptedBackup, deviceId)); + log.info("backup file successfully updated on google drive"); } return true; } catch (Throwable t) { - log.error("failed to save channels backup in google drive", t); + log.error("failed to save channels backup on google drive", t); + if (t instanceof GoogleAuthIOException || t instanceof GoogleAuthException) { + BackupHelper.GoogleDrive.disableGDriveBackup(context); + } else if (t.getCause() != null) { + final Throwable cause = t.getCause(); + if (cause instanceof GoogleAuthIOException || cause instanceof GoogleAuthException) { + BackupHelper.GoogleDrive.disableGDriveBackup(context); + } + } return false; } } - private Task createBackupOnDrive(final byte[] encryptedBackup, final DriveResourceClient driveResourceClient, final String backupFileName) { - final Task appFolderTask = driveResourceClient.getAppFolder(); - final Task contentsTask = driveResourceClient.createContents(); - return Tasks.whenAll(appFolderTask, contentsTask).continueWithTask(task -> { - - // write encrypted backup as file content - final DriveContents contents = contentsTask.getResult(); - final InputStream i = new ByteArrayInputStream(encryptedBackup); - ByteStreams.copy(i, contents.getOutputStream()); - i.close(); - - final MetadataChangeSet changeSet = new MetadataChangeSet.Builder() - .setTitle(backupFileName) - .setCustomProperty(new CustomPropertyKey(Constants.BACKUP_META_DEVICE_ID, CustomPropertyKey.PUBLIC), - WalletUtils.getDeviceId(getApplicationContext())) - .setMimeType("application/octet-stream") - .build(); - - return driveResourceClient.createFile(appFolderTask.getResult(), changeSet, contents); - }); - } - - private Task updateBackupOnDrive(final byte[] encryptedBackup, final DriveResourceClient driveResourceClient, final DriveFile driveFile) { - return driveResourceClient.openFile(driveFile, DriveFile.MODE_WRITE_ONLY).continueWithTask(contentsTask -> { - - // write encrypted backup as file content - final DriveContents contents = contentsTask.getResult(); - final InputStream i = new ByteArrayInputStream(encryptedBackup); - ByteStreams.copy(i, contents.getOutputStream()); - i.close(); - - return driveResourceClient.commitContents(contents, null); - }).continueWithTask(aVoid -> { - final MetadataChangeSet changeSet = new MetadataChangeSet.Builder() - .setCustomProperty(new CustomPropertyKey(Constants.BACKUP_META_DEVICE_ID, CustomPropertyKey.PUBLIC), - WalletUtils.getDeviceId(getApplicationContext())) - .build(); - - return driveResourceClient.updateMetadata(driveFile, changeSet); - }); - } - public static void scheduleWorkASAP(final String seedHash, final ByteVector32 backupKey) { WorkManager.getInstance() .beginUniqueWork("ChannelsBackup", ExistingWorkPolicy.REPLACE, getOneTimeBackupRequest(seedHash, backupKey)) diff --git a/app/src/main/java/fr/acinq/eclair/wallet/utils/BackupHelper.java b/app/src/main/java/fr/acinq/eclair/wallet/utils/BackupHelper.java new file mode 100644 index 00000000..a7478c71 --- /dev/null +++ b/app/src/main/java/fr/acinq/eclair/wallet/utils/BackupHelper.java @@ -0,0 +1,279 @@ +/* + * Copyright 2019 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.wallet.utils; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Environment; +import android.preference.PreferenceManager; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import com.google.android.gms.auth.api.signin.GoogleSignIn; +import com.google.android.gms.auth.api.signin.GoogleSignInAccount; +import com.google.android.gms.auth.api.signin.GoogleSignInOptions; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; +import com.google.android.gms.common.api.Scope; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential; +import com.google.api.client.http.ByteArrayContent; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.DriveScopes; +import com.google.api.services.drive.model.FileList; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import fr.acinq.eclair.wallet.BuildConfig; +import fr.acinq.eclair.wallet.R; + +public interface BackupHelper { + + interface Local { + + static boolean isExternalStorageWritable() { + return Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()); + } + + static boolean hasLocalAccess(final Context context) { + return ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; + } + + static File getBackupFile(final String backupFileName) throws EclairException.ExternalStorageUnavailableException { + if (!isExternalStorageWritable()) { + throw new EclairException.ExternalStorageUnavailableException(); + } + + final File storage = Environment.getExternalStorageDirectory(); + if (!storage.canWrite()) { + throw new EclairException.ExternalStorageUnavailableException(); + } + + final File publicDir = new File(storage, Constants.ECLAIR_BACKUP_DIR); + final File chainDir = new File(publicDir, BuildConfig.CHAIN); + final File backup = new File(chainDir, backupFileName); + + if (!backup.exists()) { + if (!chainDir.exists() && !chainDir.mkdirs()) { + throw new EclairException.ExternalStorageUnavailableException(); + } + } + + return backup; + } + } + + interface GoogleDrive { + + Logger log = LoggerFactory.getLogger(GoogleDrive.class); + + static Drive getDriveServiceFromAccount(final Context context, final GoogleSignInAccount signInAccount) { + final GoogleAccountCredential credential = GoogleAccountCredential + .usingOAuth2(context, BackupHelper.GoogleDrive.getGdriveScope()) + .setSelectedAccount(signInAccount.getAccount()); + return new Drive.Builder(new NetHttpTransport(), new GsonFactory(), credential) + .setApplicationName(context.getString(R.string.app_name)) + .build(); + } + + static com.google.api.services.drive.model.File filterBestBackup(@NonNull FileList files) { + log.debug("found {} backups on gdrive", files.getFiles().size()); + return files.getFiles().isEmpty() ? null : files.getFiles().get(0); + } + + /** + * Retrieves a list of (metadata) backup files from gdrive (including the hidden app data folder) + */ + static Task listBackupsLegacy(@NonNull final Executor executor, @NonNull final Drive drive, @NonNull final String fileName) { + log.debug("retrieving list of backups from gdrive for name={} (including hidden app data folder)", fileName); + return Tasks.call(executor, () -> drive.files().list() + .setQ("name='" + fileName + "'") + .setSpaces("drive,appDataFolder") + .setFields("files(id,name,modifiedTime,appProperties)") + .setOrderBy("modifiedTime desc") + .execute()); + } + + /** + * Retrieves a list of (metadata) backup files from gdrive. Only searches in the public eclair-mobile folder. + */ + static Task listBackups(@NonNull final Executor executor, @NonNull final Drive drive, @NonNull final String fileName) { + log.debug("retrieving list of backups from gdrive for name={}", fileName); + return Tasks.call(executor, () -> drive.files().list() + .setQ("name='" + fileName + "'") + .setSpaces("drive") + .setFields("files(id,name,modifiedTime,appProperties)") + .setOrderBy("modifiedTime desc") + .execute()); + } + + static Task getFileContent(@NonNull final Executor executor, @NonNull final Drive drive, @NonNull final String fileId) { + log.debug("retrieving file content from gdrive for id={}", fileId); + return Tasks.call(executor, () -> { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + drive.files().get(fileId).executeMediaAndDownloadTo(baos); + return baos.toByteArray(); + }); + } + + String BACKUP_FOLDER_NAME = "eclair-mobile"; + + static com.google.api.services.drive.model.File createBackupFolderIfNeeded(@NonNull final Drive drive) throws IOException { + + // retrieve folder if it exists + final FileList folders = drive.files().list() + .setQ("name='" + BACKUP_FOLDER_NAME + "' and mimeType='application/vnd.google-apps.folder'") + .setSpaces("drive") + .setFields("files(id)").execute(); + + if (folders.isEmpty() || folders.getFiles().isEmpty()) { + final com.google.api.services.drive.model.File folderMeta = new com.google.api.services.drive.model.File(); + folderMeta.setParents(Collections.singletonList("root")) + .setMimeType("application/vnd.google-apps.folder") + .setName("eclair-mobile"); + return drive.files().create(folderMeta).setFields("id,parents,mimeType").execute(); + } else { + return folders.getFiles().get(0); + } + } + + @NonNull + static Task createBackup(@NonNull final Executor executor, @NonNull final Drive drive, final String fileName, final byte[] encryptedData, final String deviceId) { + log.info("creating new backup file on gdrive with name={}", fileName); + return Tasks.call(executor, () -> { + + // 1 - create folder + final com.google.api.services.drive.model.File folder = createBackupFolderIfNeeded(drive); + + // 2 - metadata + final HashMap props = new HashMap<>(); + props.put(Constants.BACKUP_META_DEVICE_ID, deviceId); + final com.google.api.services.drive.model.File metadata = new com.google.api.services.drive.model.File() + .setParents(Collections.singletonList(folder.getId())) + .setAppProperties(props) + .setMimeType("application/octet-stream") + .setName(fileName); + + // 3 - content + final ByteArrayContent content = new ByteArrayContent("application/octet-stream", encryptedData); + + // 4 - execute + final com.google.api.services.drive.model.File file = drive.files() + .create(metadata, content) + .setFields("id,parents,appProperties") + .execute(); + if (file == null) { + throw new IOException("failed to create file on gdrive with null result"); + } + return file.getId(); + }); + } + + @NonNull + static Task updateBackup(@NonNull final Executor executor, @NonNull final Drive drive, final String fileId, final byte[] encryptedData, final String deviceId) { + log.info("updating backup file in gdrive with id={}", fileId); + return Tasks.call(executor, () -> { + + // 1 - metadata + final HashMap props = new HashMap<>(); + props.put(Constants.BACKUP_META_DEVICE_ID, deviceId); + final com.google.api.services.drive.model.File metadata = new com.google.api.services.drive.model.File() + .setAppProperties(props) + .setMimeType("application/octet-stream"); + + // 2 - content + final ByteArrayContent content = new ByteArrayContent("application/octet-stream", encryptedData); + + // 3 - execute + final com.google.api.services.drive.model.File file = drive.files().update(fileId, metadata, content).execute(); + return file.getId(); + }); + } + + static boolean isGDriveAvailable(final Context context) { + final int connectionResult = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context); + if (connectionResult != ConnectionResult.SUCCESS) { + log.info("Google play services are not available (code {})", connectionResult); + return false; + } else { + return true; + } + } + + static Set getGdriveScope() { + final Set requiredScopes = new HashSet<>(2); + requiredScopes.add(DriveScopes.DRIVE_FILE); + requiredScopes.add(DriveScopes.DRIVE_APPDATA); + return requiredScopes; + } + + static GoogleSignInAccount getSigninAccount(final Context context) { + final GoogleSignInOptions opts = getGoogleSigninOptions(); + final GoogleSignInAccount account = GoogleSignIn.getLastSignedInAccount(context); + if (GoogleSignIn.hasPermissions(account, opts.getScopeArray())) { + return account; + } else { + try { + Tasks.await(Tasks.call(Executors.newSingleThreadExecutor(), () -> GoogleSignIn.getClient(context, opts).revokeAccess())); + } catch (Exception e) { + log.warn("could not revoke gdrive access: {}", e.getLocalizedMessage()); + } + return null; + } + } + + static GoogleSignInOptions getGoogleSigninOptions() { + return new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestScopes(new Scope(DriveScopes.DRIVE_FILE)) + .requestScopes(new Scope(DriveScopes.DRIVE_APPDATA)) + .build(); + } + + static void disableGDriveBackup(final Context context) { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean(Constants.SETTING_CHANNELS_BACKUP_GOOGLEDRIVE_ENABLED, false) + .apply(); + } + + static void enableGDriveBackup(final Context context) { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean(Constants.SETTING_CHANNELS_BACKUP_GOOGLEDRIVE_ENABLED, true) + .apply(); + } + + static boolean isGDriveEnabled(final Context context) { + return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(Constants.SETTING_CHANNELS_BACKUP_GOOGLEDRIVE_ENABLED, false); + } + } +} diff --git a/app/src/main/java/fr/acinq/eclair/wallet/utils/EncryptedBackup.java b/app/src/main/java/fr/acinq/eclair/wallet/utils/EncryptedBackup.java index 43f3da56..544f951e 100644 --- a/app/src/main/java/fr/acinq/eclair/wallet/utils/EncryptedBackup.java +++ b/app/src/main/java/fr/acinq/eclair/wallet/utils/EncryptedBackup.java @@ -20,8 +20,13 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.Closeable; import java.io.IOException; import java.security.GeneralSecurityException; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterOutputStream; import fr.acinq.bitcoin.ByteVector32; import fr.acinq.bitcoin.DeterministicWallet; @@ -45,6 +50,11 @@ public class EncryptedBackup extends EncryptedData { */ public final static byte BACKUP_VERSION_2 = 2; + /** + * Version 3 compresses the data content before encrypting (uses ZLIB, see {@link Deflater}). + */ + public final static byte BACKUP_VERSION_3 = 3; + private static final int IV_LENGTH_V1 = 16; private static final int MAC_LENGTH_V1 = 32; @@ -61,11 +71,68 @@ private EncryptedBackup(int version, AesCbcWithIntegrity.CipherTextIvMac civ) { * @return a encrypted backup object ready to be serialized * @throws GeneralSecurityException */ - public static EncryptedBackup encrypt(final byte[] data, final AesCbcWithIntegrity.SecretKeys key, final int version) throws GeneralSecurityException { - final AesCbcWithIntegrity.CipherTextIvMac civ = AesCbcWithIntegrity.encrypt(data, key); + public static EncryptedBackup encrypt(final byte[] data, final AesCbcWithIntegrity.SecretKeys key, final int version) throws GeneralSecurityException, IOException { + final byte[] finalData = version >= BACKUP_VERSION_3 ? compressByteArray(data) : data; + final AesCbcWithIntegrity.CipherTextIvMac civ = AesCbcWithIntegrity.encrypt(finalData, key); return new EncryptedBackup(version, civ); } + /** + * Decrypt an encrypted backup object with a password and returns a byte array. If version >= 3, this + * method also unzips the decrypted data. + * + * @param key key encrypting the data + * @return a byte array containing the decrypted data + * @throws GeneralSecurityException if the password is not correct + */ + public byte[] decrypt(final AesCbcWithIntegrity.SecretKeys key) throws GeneralSecurityException, IOException { + final byte[] decryptedData = AesCbcWithIntegrity.decrypt(civ, key); + return getVersion() >= BACKUP_VERSION_3 ? decompressByteArray(decryptedData) : decryptedData; + } + + private static byte[] compressByteArray(final byte[] data) throws IOException { + byte[] result = data; + final ByteArrayOutputStream baos = new ByteArrayOutputStream(data.length); + final Deflater deflater = new Deflater(); + final DeflaterOutputStream dos = new DeflaterOutputStream(baos, deflater); + try { + dos.write(data); + dos.finish(); + dos.close(); + result = baos.toByteArray(); + } finally { + deflater.end(); + closeSilent(dos); + } + return result; + } + + private static byte[] decompressByteArray(final byte[] data) throws IOException { + byte[] result = data; + final ByteArrayOutputStream baos = new ByteArrayOutputStream(data.length); + final Inflater inflater = new Inflater(); + final InflaterOutputStream ios = new InflaterOutputStream(baos, inflater); + try { + ios.write(data); + ios.finish(); + ios.close(); + result = baos.toByteArray(); + } finally { + inflater.end(); + closeSilent(ios); + } + return result; + } + + private static void closeSilent(final Closeable closeable) { + try { + if (closeable != null) { + closeable.close(); + } + } catch (IOException ignored) { + } + } + /** * Read an array of byte and deserializes it as an EncryptedBackup object. * @@ -75,7 +142,7 @@ public static EncryptedBackup encrypt(final byte[] data, final AesCbcWithIntegri public static EncryptedBackup read(final byte[] serialized) { final ByteArrayInputStream stream = new ByteArrayInputStream(serialized); final int version = stream.read(); - if (version == BACKUP_VERSION_1 || version == BACKUP_VERSION_2) { + if (version == BACKUP_VERSION_1 || version == BACKUP_VERSION_2 || version == BACKUP_VERSION_3) { final byte[] iv = new byte[IV_LENGTH_V1]; stream.read(iv, 0, IV_LENGTH_V1); final byte[] mac = new byte[MAC_LENGTH_V1]; @@ -113,7 +180,7 @@ public static ByteVector32 generateBackupKey_v2(final DeterministicWallet.Extend */ @Override public byte[] write() throws IOException { - if (version == BACKUP_VERSION_1 || version == BACKUP_VERSION_2) { + if (version == BACKUP_VERSION_1 || version == BACKUP_VERSION_2 || version == BACKUP_VERSION_3) { if (civ.getIv().length != IV_LENGTH_V1 || civ.getMac().length != MAC_LENGTH_V1) { throw new RuntimeException("could not serialize backup because fields are not of the correct length"); } diff --git a/app/src/main/java/fr/acinq/eclair/wallet/utils/EncryptedData.java b/app/src/main/java/fr/acinq/eclair/wallet/utils/EncryptedData.java index 6552fb42..29602886 100644 --- a/app/src/main/java/fr/acinq/eclair/wallet/utils/EncryptedData.java +++ b/app/src/main/java/fr/acinq/eclair/wallet/utils/EncryptedData.java @@ -43,18 +43,6 @@ public byte[] write() throws IOException { throw new UnsupportedOperationException(); } - /** - * Decrypt an encrypted data object with a password and returns a byte array - * - * @param password password protecting the data - * @return a byte array containing the decrypted data - * @throws GeneralSecurityException if the password is not correct - */ - public byte[] decrypt(final String password) throws GeneralSecurityException { - final AesCbcWithIntegrity.SecretKeys sk = AesCbcWithIntegrity.generateKeyFromPassword(password, salt); - return AesCbcWithIntegrity.decrypt(civ, sk); - } - public static AesCbcWithIntegrity.SecretKeys secretKeyFromBinaryKey(final ByteVector32 key) { final byte[] keyBytes = key.bytes().toArray(); final byte[] confidentialityKeyBytes = new byte[16]; @@ -67,7 +55,7 @@ public static AesCbcWithIntegrity.SecretKeys secretKeyFromBinaryKey(final ByteVe return new AesCbcWithIntegrity.SecretKeys(confidentialityKey, integrityKey); } - public byte[] decrypt(final AesCbcWithIntegrity.SecretKeys key) throws GeneralSecurityException { + public byte[] decrypt(final AesCbcWithIntegrity.SecretKeys key) throws GeneralSecurityException, IOException { return AesCbcWithIntegrity.decrypt(civ, key); } diff --git a/app/src/main/java/fr/acinq/eclair/wallet/utils/EncryptedSeed.java b/app/src/main/java/fr/acinq/eclair/wallet/utils/EncryptedSeed.java index 1117ea6b..1b81840f 100644 --- a/app/src/main/java/fr/acinq/eclair/wallet/utils/EncryptedSeed.java +++ b/app/src/main/java/fr/acinq/eclair/wallet/utils/EncryptedSeed.java @@ -34,6 +34,18 @@ private EncryptedSeed(int version, byte[] salt, AesCbcWithIntegrity.CipherTextIv super(version, salt, civ); } + /** + * Decrypt an encrypted seed object with a password and returns a byte array. + * + * @param password password protecting the data + * @return a byte array containing the decrypted data + * @throws GeneralSecurityException if the password is not correct + */ + public byte[] decrypt(final String password) throws GeneralSecurityException { + final AesCbcWithIntegrity.SecretKeys sk = AesCbcWithIntegrity.generateKeyFromPassword(password, salt); + return AesCbcWithIntegrity.decrypt(civ, sk); + } + /** * Encrypt a non encrypted seed with AES CBC and return an object containing the encrypted seed. * diff --git a/app/src/main/res/layout/activity_restore_channels_backup.xml b/app/src/main/res/layout/activity_restore_channels_backup.xml index 22dfdf96..c8484a33 100644 --- a/app/src/main/res/layout/activity_restore_channels_backup.xml +++ b/app/src/main/res/layout/activity_restore_channels_backup.xml @@ -223,7 +223,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:visibility="@{restoreStep == Constants.RESTORE_BACKUP_FOUND || restoreStep == Constants.RESTORE_BACKUP_FOUND_WITH_CONFLICT ? View.VISIBLE : View.GONE}" - app:constraint_referenced_ids="found_backup_title, found_title_separator, found_backup_text_desc_origin, found_backup_text_desc_channels_count, found_backup_text_desc_modified" /> + app:constraint_referenced_ids="found_backup_title,found_title_separator,found_backup_text_desc_origin,found_backup_text_desc_channels_count,found_backup_text_desc_modified" /> + app:constraint_referenced_ids="found_backup_title,found_backup_text_conflict,not_found_backup_text,error_text" /> Check on Google Drive Google Drive is not available on this device. Requesting access to Google Driveā€¦ + Could not access Google Drive! A backup was found but could not be read.\n\nOrigin: %1$s\nError: %2$s\n\nContact support at mobile@acinq.co There was an error when looking for backup. Please try again. Please grant access to your disk to check for local backups.