diff --git a/.travis.yml b/.travis.yml index d358323b..c4e4e394 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,28 @@ android: components: - build-tools-28.0.3 - android-28 + +licenses: + - android-sdk-license-.+ + - '.+' + +before_install: + - mkdir "$ANDROID_HOME/licenses" || true + - echo -e "\n8933bad161af4178b1185d1a37fbf41ea5269c55" >> "$ANDROID_HOME/licenses/android-sdk-license" + - echo -e "\nd56f5187479451eabf01fb78af6dfcb131a6481e" >> "$ANDROID_HOME/licenses/android-sdk-license" + - echo -e "\n24333f8a63b6825ea9c5514f83c2829b004d1fee" >> "$ANDROID_HOME/licenses/android-sdk-license" + - echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" >> "$ANDROID_HOME/licenses/android-sdk-preview-license" + +before_cache: + - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock + - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ + +cache: + directories: + - $HOME/.gradle/caches/ + - $HOME/.gradle/wrapper/ + - $HOME/.android/build-cache + deploy: provider: releases api_key: diff --git a/app/build.gradle b/app/build.gradle index fc215d3b..0545a964 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,8 @@ import groovy.xml.XmlUtil apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' android { @@ -78,5 +80,14 @@ dependencies { 'libcore.jar' ], dir: 'libs') - implementation group: 'commons-io', name: 'commons-io', version: '2.6' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + + implementation 'commons-io:commons-io:2.6' + implementation 'io.github.novacrypto:BIP39:2019.01.27' + + implementation 'androidx.core:core-ktx:1.0.2' + implementation 'androidx.preference:preference-ktx:1.0.0' + implementation 'com.google.android.material:material:1.0.0' + implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7e5e1297..6a987969 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,21 +18,24 @@ + + + + - - - - - + android:label="@string/app_name"/> : LiveData>() { + + fun observeEvent(owner: LifecycleOwner, handler: LiveEventHandler) { + val observer = LiveEventObserver(handler) + super.observe(owner, observer) + } + + class ConsumableEvent(private val content: T) { + private var consumed = false + + val contentIfNotConsumed: T? + get() { + if (consumed) return null + consumed = true + return content + } + } + + internal class LiveEventObserver(private val handler: LiveEventHandler) : Observer> { + override fun onChanged(consumableEvent: ConsumableEvent?) { + if (consumableEvent != null) { + val content = consumableEvent.contentIfNotConsumed + if (content != null) handler.onEvent(content) + } + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/LiveEventHandler.java b/app/src/main/java/com/stevesoltys/backup/LiveEventHandler.java new file mode 100644 index 00000000..22d86af0 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/LiveEventHandler.java @@ -0,0 +1,5 @@ +package com.stevesoltys.backup; + +public interface LiveEventHandler { + void onEvent(T t); +} diff --git a/app/src/main/java/com/stevesoltys/backup/MutableLiveEvent.kt b/app/src/main/java/com/stevesoltys/backup/MutableLiveEvent.kt new file mode 100644 index 00000000..7086bd40 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/MutableLiveEvent.kt @@ -0,0 +1,13 @@ +package com.stevesoltys.backup + +class MutableLiveEvent : LiveEvent() { + + fun postEvent(value: T) { + super.postValue(ConsumableEvent(value)) + } + + fun setEvent(value: T) { + super.setValue(ConsumableEvent(value)) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt new file mode 100644 index 00000000..d414cb5d --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt @@ -0,0 +1,106 @@ +package com.stevesoltys.backup + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.NotificationManager.IMPORTANCE_MIN +import android.app.backup.BackupManager +import android.app.backup.BackupProgress +import android.app.backup.IBackupObserver +import android.content.Context +import android.util.Log +import android.util.Log.INFO +import android.util.Log.isLoggable +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT +import androidx.core.app.NotificationCompat.PRIORITY_LOW +import com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport + +private const val CHANNEL_ID = "NotificationBackupObserver" +private const val NOTIFICATION_ID = 1 + +private val TAG = NotificationBackupObserver::class.java.name + +class NotificationBackupObserver( + private val context: Context, + private val userInitiated: Boolean) : IBackupObserver.Stub() { + + private val pm = context.packageManager + private val nm = context.getSystemService(NotificationManager::class.java).apply { + val title = context.getString(R.string.notification_channel_title) + val channel = NotificationChannel(CHANNEL_ID, title, IMPORTANCE_MIN).apply { + enableVibration(false) + } + createNotificationChannel(channel) + } + private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID).apply { + setSmallIcon(R.drawable.ic_cloud_upload) + priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW + } + + /** + * This method could be called several times for packages with full data backup. + * It will tell how much of backup data is already saved and how much is expected. + * + * @param currentBackupPackage The name of the package that now being backed up. + * @param backupProgress Current progress of backup for the package. + */ + override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) { + val transferred = backupProgress.bytesTransferred + val expected = backupProgress.bytesExpected + if (isLoggable(TAG, INFO)) { + Log.i(TAG, "Update. Target: $currentBackupPackage, $transferred/$expected") + } + val notification = notificationBuilder.apply { + setContentTitle(context.getString(R.string.notification_title)) + setContentText(getAppName(currentBackupPackage)) + setProgress(expected.toInt(), transferred.toInt(), false) + }.build() + nm.notify(NOTIFICATION_ID, notification) + } + + /** + * Backup of one package or initialization of one transport has completed. This + * method will be called at most one time for each package or transport, and might not + * be not called if the operation fails before backupFinished(); for example, if the + * requested package/transport does not exist. + * + * @param target The name of the package that was backed up, or of the transport + * that was initialized + * @param status Zero on success; a nonzero error code if the backup operation failed. + */ + override fun onResult(target: String, status: Int) { + if (isLoggable(TAG, INFO)) { + Log.i(TAG, "Completed. Target: $target, status: $status") + } + val title = context.getString( + if (status == 0) R.string.notification_backup_result_complete + else R.string.notification_backup_result_error + ) + val notification = notificationBuilder.apply { + setContentTitle(title) + setContentText(getAppName(target)) + }.build() + nm.notify(NOTIFICATION_ID, notification) + } + + /** + * The backup process has completed. This method will always be called, + * even if no individual package backup operations were attempted. + * + * @param status Zero on success; a nonzero error code if the backup operation + * as a whole failed. + */ + override fun backupFinished(status: Int) { + if (isLoggable(TAG, INFO)) { + Log.i(TAG, "Backup finished. Status: $status") + } + if (status == BackupManager.SUCCESS) getBackupTransport(context).backupFinished() + nm.cancel(NOTIFICATION_ID) + } + + private fun getAppName(packageId: String): CharSequence { + val appInfo = pm.getApplicationInfo(packageId, 0) + return pm.getApplicationLabel(appInfo) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/activity/MainActivity.java b/app/src/main/java/com/stevesoltys/backup/activity/MainActivity.java index 3d666957..e11e8776 100644 --- a/app/src/main/java/com/stevesoltys/backup/activity/MainActivity.java +++ b/app/src/main/java/com/stevesoltys/backup/activity/MainActivity.java @@ -11,7 +11,7 @@ import com.stevesoltys.backup.R; import static android.view.View.GONE; import static android.view.View.VISIBLE; -import static com.stevesoltys.backup.settings.SettingsManager.areBackupsScheduled; +import static com.stevesoltys.backup.settings.SettingsManagerKt.areBackupsScheduled; public class MainActivity extends Activity implements View.OnClickListener { diff --git a/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java b/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java index 5f8cea31..8fb2aeb2 100644 --- a/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java +++ b/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java @@ -21,13 +21,13 @@ import static android.content.Intent.CATEGORY_OPENABLE; import static android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION; import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; import static android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION; -import static com.stevesoltys.backup.Backup.JOB_ID_BACKGROUND_BACKUP; +import static com.stevesoltys.backup.BackupKt.JOB_ID_BACKGROUND_BACKUP; import static com.stevesoltys.backup.activity.MainActivity.OPEN_DOCUMENT_TREE_BACKUP_REQUEST_CODE; import static com.stevesoltys.backup.activity.MainActivity.OPEN_DOCUMENT_TREE_REQUEST_CODE; -import static com.stevesoltys.backup.settings.SettingsManager.getBackupFolderUri; -import static com.stevesoltys.backup.settings.SettingsManager.getBackupPassword; -import static com.stevesoltys.backup.settings.SettingsManager.setBackupFolderUri; -import static com.stevesoltys.backup.settings.SettingsManager.setBackupsScheduled; +import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupFolderUri; +import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword; +import static com.stevesoltys.backup.settings.SettingsManagerKt.setBackupFolderUri; +import static com.stevesoltys.backup.settings.SettingsManagerKt.setBackupsScheduled; import static java.util.Objects.requireNonNull; import static java.util.concurrent.TimeUnit.DAYS; diff --git a/app/src/main/java/com/stevesoltys/backup/activity/backup/CreateBackupActivityController.java b/app/src/main/java/com/stevesoltys/backup/activity/backup/CreateBackupActivityController.java index db43cd92..dc43bf28 100644 --- a/app/src/main/java/com/stevesoltys/backup/activity/backup/CreateBackupActivityController.java +++ b/app/src/main/java/com/stevesoltys/backup/activity/backup/CreateBackupActivityController.java @@ -17,11 +17,13 @@ import com.stevesoltys.backup.R; import com.stevesoltys.backup.activity.PopupWindowUtil; import com.stevesoltys.backup.service.PackageService; import com.stevesoltys.backup.service.backup.BackupService; -import com.stevesoltys.backup.settings.SettingsManager; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; +import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword; +import static com.stevesoltys.backup.settings.SettingsManagerKt.setBackupPassword; + /** * @author Steve Soltys */ @@ -69,7 +71,7 @@ class CreateBackupActivityController { } void onCreateBackupButtonClicked(Set selectedPackages, Activity parent) { - String password = SettingsManager.getBackupPassword(parent); + String password = getBackupPassword(parent); if (password == null) { showEnterPasswordAlert(selectedPackages, parent); } else { @@ -114,7 +116,7 @@ class CreateBackupActivityController { String password = passwordTextView.getText().toString(); if (originalPassword.equals(password)) { - SettingsManager.setBackupPassword(parent, password); + setBackupPassword(parent, password); backupService.backupPackageData(selectedPackages, parent); } else { diff --git a/app/src/main/java/com/stevesoltys/backup/security/KeyManager.kt b/app/src/main/java/com/stevesoltys/backup/security/KeyManager.kt new file mode 100644 index 00000000..319e219c --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/security/KeyManager.kt @@ -0,0 +1,48 @@ +package com.stevesoltys.backup.security + +import android.os.Build.VERSION.SDK_INT +import android.security.keystore.KeyProperties.* +import android.security.keystore.KeyProtection +import java.security.KeyStore +import java.security.KeyStore.SecretKeyEntry +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +private const val KEY_SIZE = 256 +private const val KEY_ALIAS = "com.stevesoltys.backup" +private const val ANDROID_KEY_STORE = "AndroidKeyStore" + +object KeyManager { + + private val keyStore by lazy { + KeyStore.getInstance(ANDROID_KEY_STORE).apply { + load(null) + } + } + + fun storeBackupKey(seed: ByteArray) { + if (seed.size < KEY_SIZE / 8) throw IllegalArgumentException() + // TODO check if using first 256 of 512 bytes produced by PBKDF2WithHmacSHA512 is safe! + val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE / 8, "AES") + val ksEntry = SecretKeyEntry(secretKeySpec) + keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection()) + } + + fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS) && + keyStore.entryInstanceOf(KEY_ALIAS, SecretKeyEntry::class.java) + + fun getBackupKey(): SecretKey { + val ksEntry = keyStore.getEntry(KEY_ALIAS, null) as SecretKeyEntry + return ksEntry.secretKey + } + + private fun getKeyProtection(): KeyProtection { + val builder = KeyProtection.Builder(PURPOSE_ENCRYPT or PURPOSE_DECRYPT) + .setBlockModes(BLOCK_MODE_GCM) + .setEncryptionPaddings(ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + if (SDK_INT >= 28) builder.setUnlockedDeviceRequired(true) + return builder.build() + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.java b/app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.java deleted file mode 100644 index cd0d2d78..00000000 --- a/app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.stevesoltys.backup.service.backup; - -import android.app.backup.BackupManager; -import android.app.backup.IBackupManager; -import android.app.job.JobParameters; -import android.app.job.JobService; -import android.content.Intent; -import android.os.RemoteException; -import android.util.Log; - -import com.stevesoltys.backup.service.PackageService; -import com.stevesoltys.backup.transport.ConfigurableBackupTransportService; - -import static android.app.backup.BackupManager.FLAG_NON_INCREMENTAL_BACKUP; -import static android.os.ServiceManager.getService; - -public class BackupJobService extends JobService { - - private final static String TAG = BackupJobService.class.getName(); - - private final IBackupManager backupManager; - private final PackageService packageService = new PackageService(); - - public BackupJobService() { - backupManager = IBackupManager.Stub.asInterface(getService("backup")); - } - - @Override - public boolean onStartJob(JobParameters params) { - Log.i(TAG, "Triggering full backup"); - startService(new Intent(this, ConfigurableBackupTransportService.class)); - try { - String[] packages = packageService.getEligiblePackages(); - // TODO use an observer to know when backups fail - int result = backupManager.requestBackup(packages, null, null, FLAG_NON_INCREMENTAL_BACKUP); - if (result == BackupManager.SUCCESS) { - Log.i(TAG, "Backup succeeded "); - } else { - Log.e(TAG, "Backup failed: " + result); - } - - // TODO show notification on backup error - } catch (RemoteException e) { - Log.e(TAG, "Error during backup: ", e); - } finally { - jobFinished(params, false); - } - return true; - } - - @Override - public boolean onStopJob(JobParameters params) { - try { - backupManager.cancelBackups(); - } catch (RemoteException e) { - Log.e(TAG, "Error cancelling backup: ", e); - } - return true; - } - -} diff --git a/app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.kt b/app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.kt new file mode 100644 index 00000000..00fbbd64 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.kt @@ -0,0 +1,63 @@ +package com.stevesoltys.backup.service.backup + +import android.app.backup.BackupManager +import android.app.backup.BackupManager.FLAG_NON_INCREMENTAL_BACKUP +import android.app.backup.BackupTransport.FLAG_USER_INITIATED +import android.app.job.JobParameters +import android.app.job.JobService +import android.content.Context +import android.content.Context.BACKUP_SERVICE +import android.content.Intent +import android.os.RemoteException +import android.util.Log +import androidx.annotation.WorkerThread +import com.stevesoltys.backup.Backup +import com.stevesoltys.backup.NotificationBackupObserver +import com.stevesoltys.backup.service.PackageService +import com.stevesoltys.backup.session.backup.BackupMonitor +import com.stevesoltys.backup.transport.ConfigurableBackupTransportService + +private val TAG = BackupJobService::class.java.name + +// TODO might not be needed, if the OS really schedules backups on its own +class BackupJobService : JobService() { + + override fun onStartJob(params: JobParameters): Boolean { + Log.i(TAG, "Triggering full backup") + try { + requestFullBackup(this) + } finally { + jobFinished(params, false) + } + return true + } + + override fun onStopJob(params: JobParameters): Boolean { + try { + Backup.backupManager.cancelBackups() + } catch (e: RemoteException) { + Log.e(TAG, "Error cancelling backup: ", e) + } + return true + } + +} + +@WorkerThread +fun requestFullBackup(context: Context) { + context.startService(Intent(context, ConfigurableBackupTransportService::class.java)) + val observer = NotificationBackupObserver(context, true) + val flags = FLAG_NON_INCREMENTAL_BACKUP or FLAG_USER_INITIATED + val packages = PackageService().eligiblePackages + val result = try { + Backup.backupManager.requestBackup(packages, observer, BackupMonitor(), flags) + } catch (e: RemoteException) { + // TODO show notification on backup error + Log.e(TAG, "Error during backup: ", e) + } + if (result == BackupManager.SUCCESS) { + Log.i(TAG, "Backup succeeded ") + } else { + Log.e(TAG, "Backup failed: $result") + } +} diff --git a/app/src/main/java/com/stevesoltys/backup/session/backup/BackupMonitor.java b/app/src/main/java/com/stevesoltys/backup/session/backup/BackupMonitor.java deleted file mode 100644 index a8d264f0..00000000 --- a/app/src/main/java/com/stevesoltys/backup/session/backup/BackupMonitor.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.stevesoltys.backup.session.backup; - -import android.app.backup.IBackupManagerMonitor; -import android.os.Bundle; -import android.util.Log; - -import static android.app.backup.BackupManagerMonitor.EXTRA_LOG_EVENT_CATEGORY; -import static android.app.backup.BackupManagerMonitor.EXTRA_LOG_EVENT_ID; -import static android.app.backup.BackupManagerMonitor.EXTRA_LOG_EVENT_PACKAGE_NAME; - -class BackupMonitor extends IBackupManagerMonitor.Stub { - - @Override - public void onEvent(Bundle bundle) { - Log.d("BackupMonitor", "ID: " + bundle.getInt(EXTRA_LOG_EVENT_ID)); - Log.d("BackupMonitor", "CATEGORY: " + bundle.getInt(EXTRA_LOG_EVENT_CATEGORY, -1)); - Log.d("BackupMonitor", "PACKAGE: " + bundle.getString(EXTRA_LOG_EVENT_PACKAGE_NAME, "?")); - } - -} diff --git a/app/src/main/java/com/stevesoltys/backup/session/backup/BackupMonitor.kt b/app/src/main/java/com/stevesoltys/backup/session/backup/BackupMonitor.kt new file mode 100644 index 00000000..6660d94a --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/session/backup/BackupMonitor.kt @@ -0,0 +1,20 @@ +package com.stevesoltys.backup.session.backup + +import android.app.backup.BackupManagerMonitor.* +import android.app.backup.IBackupManagerMonitor +import android.os.Bundle +import android.util.Log +import android.util.Log.DEBUG + +private val TAG = BackupMonitor::class.java.name + +class BackupMonitor : IBackupManagerMonitor.Stub() { + + override fun onEvent(bundle: Bundle) { + if (!Log.isLoggable(TAG, DEBUG)) return + Log.d(TAG, "ID: " + bundle.getInt(EXTRA_LOG_EVENT_ID)) + Log.d(TAG, "CATEGORY: " + bundle.getInt(EXTRA_LOG_EVENT_CATEGORY, -1)) + Log.d(TAG, "PACKAGE: " + bundle.getString(EXTRA_LOG_EVENT_PACKAGE_NAME, "?")) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt new file mode 100644 index 00000000..140ea439 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt @@ -0,0 +1,59 @@ +package com.stevesoltys.backup.settings + +import android.app.Activity.RESULT_OK +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.Intent.* +import android.os.Bundle +import android.widget.Toast +import android.widget.Toast.LENGTH_LONG +import androidx.lifecycle.ViewModelProviders +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import com.stevesoltys.backup.R + +private val TAG = BackupLocationFragment::class.java.name + +class BackupLocationFragment : PreferenceFragmentCompat() { + + private lateinit var viewModel: SettingsViewModel + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.backup_location, rootKey) + + requireActivity().setTitle(R.string.settings_backup_location_title) + + viewModel = ViewModelProviders.of(requireActivity()).get(SettingsViewModel::class.java) + + val externalStorage = Preference(requireContext()).apply { + setIcon(R.drawable.ic_storage) + setTitle(R.string.settings_backup_external_storage) + setOnPreferenceClickListener { + showChooseFolderActivity() + true + } + } + preferenceScreen.addPreference(externalStorage) + } + + private fun showChooseFolderActivity() { + val openTreeIntent = Intent(ACTION_OPEN_DOCUMENT_TREE) + openTreeIntent.addFlags(FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION) + try { + val documentChooser = createChooser(openTreeIntent, null) + startActivityForResult(documentChooser, REQUEST_CODE_OPEN_DOCUMENT_TREE) + } catch (ex: ActivityNotFoundException) { + Toast.makeText(requireContext(), "Please install a file manager.", LENGTH_LONG).show() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) { + if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_OPEN_DOCUMENT_TREE) { + viewModel.handleChooseFolderResult(result) + } else { + super.onActivityResult(requestCode, resultCode, result) + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeActivity.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeActivity.kt new file mode 100644 index 00000000..0e737f94 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeActivity.kt @@ -0,0 +1,55 @@ +package com.stevesoltys.backup.settings + +import android.os.Bundle +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModelProviders +import com.stevesoltys.backup.LiveEventHandler +import com.stevesoltys.backup.R + +class RecoveryCodeActivity : AppCompatActivity() { + + private lateinit var viewModel: RecoveryCodeViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.activity_recovery_code) + + viewModel = ViewModelProviders.of(this).get(RecoveryCodeViewModel::class.java) + viewModel.confirmButtonClicked.observeEvent(this, LiveEventHandler { clicked -> + if (clicked) { + val tag = "Confirm" + supportFragmentManager.beginTransaction() + .replace(R.id.fragment, RecoveryCodeInputFragment(), tag) + .addToBackStack(tag) + .commit() + } + }) + viewModel.recoveryCodeSaved.observeEvent(this, LiveEventHandler { saved -> + if (saved) { + setResult(RESULT_OK) + finishAfterTransition() + } + }) + + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + + if (savedInstanceState == null) { + supportFragmentManager.beginTransaction() + .add(R.id.fragment, RecoveryCodeOutputFragment(), "Code") + .commit() + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when { + item.itemId == android.R.id.home -> { + onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeAdapter.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeAdapter.kt new file mode 100644 index 00000000..cc4e009c --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeAdapter.kt @@ -0,0 +1,37 @@ +package com.stevesoltys.backup.settings + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Adapter +import com.stevesoltys.backup.R + +class RecoveryCodeAdapter(private val items: List) : Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecoveryCodeViewHolder { + val v = LayoutInflater.from(parent.context) + .inflate(R.layout.list_item_recovery_code_output, parent, false) as View + return RecoveryCodeViewHolder(v) + } + + override fun getItemCount() = items.size + + override fun onBindViewHolder(holder: RecoveryCodeViewHolder, position: Int) { + holder.bind(position + 1, items[position]) + } + +} + +class RecoveryCodeViewHolder(v: View) : RecyclerView.ViewHolder(v) { + + private val num = v.findViewById(R.id.num) + private val word = v.findViewById(R.id.word) + + internal fun bind(number: Int, item: CharSequence) { + num.text = number.toString() + word.text = item + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeInputFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeInputFragment.kt new file mode 100644 index 00000000..26918c49 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeInputFragment.kt @@ -0,0 +1,104 @@ +package com.stevesoltys.backup.settings + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.View.OnFocusChangeListener +import android.view.ViewGroup +import android.widget.Toast +import android.widget.Toast.LENGTH_LONG +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProviders +import com.stevesoltys.backup.R +import io.github.novacrypto.bip39.Validation.InvalidChecksumException +import io.github.novacrypto.bip39.Validation.WordNotFoundException +import kotlinx.android.synthetic.main.fragment_recovery_code_input.* +import kotlinx.android.synthetic.main.recovery_code_input.* + +class RecoveryCodeInputFragment : Fragment() { + + private lateinit var viewModel: RecoveryCodeViewModel + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_recovery_code_input, container, false) + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + viewModel = ViewModelProviders.of(requireActivity()).get(RecoveryCodeViewModel::class.java) + + for (i in 0 until WORD_NUM) { + val wordLayout = getWordLayout(i) + wordLayout.editText!!.onFocusChangeListener = OnFocusChangeListener { _, focus -> + if (!focus) wordLayout.isErrorEnabled = false + } + } + doneButton.setOnClickListener { done() } + + if (Build.TYPE == "userdebug") debugPreFill() + } + + private fun getInput(): List = ArrayList(WORD_NUM).apply { + for (i in 0 until WORD_NUM) add(getWordLayout(i).editText!!.text.toString()) + } + + private fun done() { + val input = getInput() + if (!allFilledOut(input)) return + try { + viewModel.validateAndContinue(input) + } catch (e: InvalidChecksumException) { + Toast.makeText(context, R.string.recovery_code_error_checksum_word, LENGTH_LONG).show() + } catch (e: WordNotFoundException) { + showWrongWordError(input, e) + } + } + + private fun allFilledOut(input: List): Boolean { + for (i in 0 until input.size) { + if (input[i].isNotEmpty()) continue + showError(i, getString(R.string.recovery_code_error_empty_word)) + return false + } + return true + } + + private fun showWrongWordError(input: List, e: WordNotFoundException) { + val i = input.indexOf(e.word) + if (i == -1) throw AssertionError() + showError(i, getString(R.string.recovery_code_error_invalid_word, e.suggestion1, e.suggestion2)) + } + + private fun showError(i: Int, errorMsg: CharSequence) { + getWordLayout(i).apply { + error = errorMsg + requestFocus() + } + } + + private fun getWordLayout(i: Int) = when (i + 1) { + 1 -> wordLayout1 + 2 -> wordLayout2 + 3 -> wordLayout3 + 4 -> wordLayout4 + 5 -> wordLayout5 + 6 -> wordLayout6 + 7 -> wordLayout7 + 8 -> wordLayout8 + 9 -> wordLayout9 + 10 -> wordLayout10 + 11 -> wordLayout11 + 12 -> wordLayout12 + else -> throw IllegalArgumentException() + } + + private fun debugPreFill() { + val words = viewModel.wordList + for (i in 0 until words.size) { + getWordLayout(i).editText!!.setText(words[i]) + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeOutputFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeOutputFragment.kt new file mode 100644 index 00000000..724cb5a1 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeOutputFragment.kt @@ -0,0 +1,45 @@ +package com.stevesoltys.backup.settings + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.stevesoltys.backup.R +import kotlinx.android.synthetic.main.fragment_recovery_code_output.* + +class RecoveryCodeOutputFragment : Fragment() { + + private lateinit var viewModel: RecoveryCodeViewModel + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_recovery_code_output, container, false) + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + viewModel = ViewModelProviders.of(requireActivity()).get(RecoveryCodeViewModel::class.java) + + setGridParameters(wordList) + wordList.adapter = RecoveryCodeAdapter(viewModel.wordList) + + confirmCodeButton.setOnClickListener { viewModel.onConfirmButtonClicked() } + } + + private fun setGridParameters(list: RecyclerView) { + val layoutManager = list.layoutManager as GridLayoutManager + if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + layoutManager.orientation = RecyclerView.VERTICAL + layoutManager.spanCount = 4 + } else { + layoutManager.orientation = RecyclerView.HORIZONTAL + layoutManager.spanCount = 6 + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt new file mode 100644 index 00000000..a64f4ec8 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt @@ -0,0 +1,59 @@ +package com.stevesoltys.backup.settings + +import android.app.Application +import android.util.ByteStringUtils.toHexString +import androidx.lifecycle.AndroidViewModel +import com.stevesoltys.backup.LiveEvent +import com.stevesoltys.backup.MutableLiveEvent +import com.stevesoltys.backup.security.KeyManager +import io.github.novacrypto.bip39.* +import io.github.novacrypto.bip39.Validation.InvalidChecksumException +import io.github.novacrypto.bip39.Validation.InvalidWordCountException +import io.github.novacrypto.bip39.Validation.UnexpectedWhiteSpaceException +import io.github.novacrypto.bip39.Validation.WordNotFoundException +import io.github.novacrypto.bip39.wordlists.English +import java.security.SecureRandom +import java.util.* + +internal const val WORD_NUM = 12 + +class RecoveryCodeViewModel(application: Application) : AndroidViewModel(application) { + + internal val wordList: List by lazy { + val items: ArrayList = ArrayList(WORD_NUM) + // TODO factor out entropy generation + val entropy = ByteArray(Words.TWELVE.byteLength()) + SecureRandom().nextBytes(entropy) + MnemonicGenerator(English.INSTANCE).createMnemonic(entropy) { + if (it != " ") items.add(it) + } + items + } + + private val mConfirmButtonClicked = MutableLiveEvent() + internal val confirmButtonClicked: LiveEvent = mConfirmButtonClicked + internal fun onConfirmButtonClicked() = mConfirmButtonClicked.setEvent(true) + + private val mRecoveryCodeSaved = MutableLiveEvent() + internal val recoveryCodeSaved: LiveEvent = mRecoveryCodeSaved + + @Throws(WordNotFoundException::class, InvalidChecksumException::class) + fun validateAndContinue(input: List) { + try { + MnemonicValidator.ofWordList(English.INSTANCE).validate(input) + } catch (e: UnexpectedWhiteSpaceException) { + throw AssertionError(e) + } catch (e: InvalidWordCountException) { + throw AssertionError(e) + } + val mnemonic = input.joinToString(" ") + val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "") + KeyManager.storeBackupKey(seed) + + // TODO remove once encryption/decryption uses key from KeyStore + setBackupPassword(getApplication(), toHexString(seed)) + + mRecoveryCodeSaved.setEvent(true) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt new file mode 100644 index 00000000..18a46926 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt @@ -0,0 +1,82 @@ +package com.stevesoltys.backup.settings + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProviders +import com.stevesoltys.backup.LiveEventHandler +import com.stevesoltys.backup.R + +private val TAG = SettingsActivity::class.java.name + +const val REQUEST_CODE_OPEN_DOCUMENT_TREE = 1 +const val REQUEST_CODE_RECOVERY_CODE = 2 + +class SettingsActivity : AppCompatActivity() { + + private lateinit var viewModel: SettingsViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.activity_settings) + + viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java) + viewModel.onLocationSet.observeEvent(this, LiveEventHandler { wasEmptyBefore -> + if (wasEmptyBefore) showFragment(SettingsFragment()) + else supportFragmentManager.popBackStack() + }) + viewModel.chooseBackupLocation.observeEvent(this, LiveEventHandler { show -> + if (show) showFragment(BackupLocationFragment(), true) + }) + + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + + if (savedInstanceState == null) showFragment(SettingsFragment()) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, result: Intent?) { + if (resultCode != RESULT_OK) { + Log.w(TAG, "Error in activity result: $requestCode") + finishAfterTransition() + } else { + super.onActivityResult(requestCode, resultCode, result) + } + } + + override fun onStart() { + super.onStart() + if (isFinishing) return + + // check that backup is provisioned + if (!viewModel.recoveryCodeIsSet()) { + showRecoveryCodeActivity() + } else if (!viewModel.locationIsSet()) { + showFragment(BackupLocationFragment()) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when { + item.itemId == android.R.id.home -> { + onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + + private fun showRecoveryCodeActivity() { + val intent = Intent(this, RecoveryCodeActivity::class.java) + startActivityForResult(intent, REQUEST_CODE_RECOVERY_CODE) + } + + private fun showFragment(f: Fragment, addToBackStack: Boolean = false) { + val fragmentTransaction = supportFragmentManager.beginTransaction() + .replace(R.id.fragment, f) + if (addToBackStack) fragmentTransaction.addToBackStack(null) + fragmentTransaction.commit() + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt new file mode 100644 index 00000000..ccc0bb35 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt @@ -0,0 +1,108 @@ +package com.stevesoltys.backup.settings + +import android.content.Context.BACKUP_SERVICE +import android.os.Bundle +import android.os.RemoteException +import android.provider.Settings +import android.provider.Settings.Secure.BACKUP_AUTO_RESTORE +import android.util.Log +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.widget.Toast +import androidx.lifecycle.ViewModelProviders +import androidx.preference.Preference.OnPreferenceChangeListener +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.TwoStatePreference +import com.stevesoltys.backup.Backup +import com.stevesoltys.backup.R + +private val TAG = SettingsFragment::class.java.name + +class SettingsFragment : PreferenceFragmentCompat() { + + private val backupManager = Backup.backupManager + + private lateinit var viewModel: SettingsViewModel + + private lateinit var backup: TwoStatePreference + private lateinit var autoRestore: TwoStatePreference + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.settings, rootKey) + setHasOptionsMenu(true) + + viewModel = ViewModelProviders.of(requireActivity()).get(SettingsViewModel::class.java) + + backup = findPreference("backup") as TwoStatePreference + backup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue -> + val enabled = newValue as Boolean + try { + backupManager.isBackupEnabled = enabled + return@OnPreferenceChangeListener true + } catch (e: RemoteException) { + e.printStackTrace() + backup.isChecked = !enabled + return@OnPreferenceChangeListener false + } + } + + val backupLocation = findPreference("backup_location") + backupLocation.setOnPreferenceClickListener { + viewModel.chooseBackupLocation() + true + } + + autoRestore = findPreference("auto_restore") as TwoStatePreference + autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue -> + val enabled = newValue as Boolean + try { + backupManager.setAutoRestore(enabled) + return@OnPreferenceChangeListener true + } catch (e: RemoteException) { + Log.e(TAG, "Error communicating with BackupManager", e) + autoRestore.isChecked = !enabled + return@OnPreferenceChangeListener false + } + } + } + + override fun onStart() { + super.onStart() + + // we need to re-set the title when returning to this fragment + requireActivity().setTitle(R.string.app_name) + + try { + backup.isChecked = backupManager.isBackupEnabled + backup.isEnabled = true + } catch (e: RemoteException) { + Log.e(TAG, "Error communicating with BackupManager", e) + backup.isEnabled = false + } + + val resolver = requireContext().contentResolver + autoRestore.isChecked = Settings.Secure.getInt(resolver, BACKUP_AUTO_RESTORE, 1) == 1 + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.settings_menu, menu) + if (resources.getBoolean(R.bool.show_restore_in_settings)) { + menu.findItem(R.id.action_restore).isVisible = true + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when { + item.itemId == R.id.action_backup -> { + viewModel.backupNow() + true + } + item.itemId == R.id.action_restore -> { + Toast.makeText(requireContext(), "Not yet implemented", Toast.LENGTH_SHORT).show() + true + } + else -> super.onOptionsItemSelected(item) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.java b/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.java deleted file mode 100644 index fa4b41ad..00000000 --- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.stevesoltys.backup.settings; - -import android.annotation.Nullable; -import android.content.Context; -import android.net.Uri; - -import static android.preference.PreferenceManager.getDefaultSharedPreferences; - -public class SettingsManager { - - private static final String PREF_KEY_BACKUP_URI = "backupUri"; - private static final String PREF_KEY_BACKUP_PASSWORD = "backupLegacyPassword"; - private static final String PREF_KEY_BACKUPS_SCHEDULED = "backupsScheduled"; - - public static void setBackupFolderUri(Context context, Uri uri) { - getDefaultSharedPreferences(context) - .edit() - .putString(PREF_KEY_BACKUP_URI, uri.toString()) - .apply(); - } - - @Nullable - public static Uri getBackupFolderUri(Context context) { - String uriStr = getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_URI, null); - if (uriStr == null) return null; - return Uri.parse(uriStr); - } - - /** - * This is insecure and not supposed to be part of a release, - * but rather an intermediate step towards a generated passphrase. - */ - public static void setBackupPassword(Context context, String password) { - getDefaultSharedPreferences(context) - .edit() - .putString(PREF_KEY_BACKUP_PASSWORD, password) - .apply(); - } - - @Nullable - public static String getBackupPassword(Context context) { - return getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_PASSWORD, null); - } - - public static void setBackupsScheduled(Context context) { - getDefaultSharedPreferences(context) - .edit() - .putBoolean(PREF_KEY_BACKUPS_SCHEDULED, true) - .apply(); - } - - @Nullable - public static Boolean areBackupsScheduled(Context context) { - return getDefaultSharedPreferences(context).getBoolean(PREF_KEY_BACKUPS_SCHEDULED, false); - } - -} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt new file mode 100644 index 00000000..51ddc5d1 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt @@ -0,0 +1,50 @@ +package com.stevesoltys.backup.settings + +import android.content.Context +import android.net.Uri +import android.preference.PreferenceManager.getDefaultSharedPreferences + +private const val PREF_KEY_BACKUP_URI = "backupUri" +private const val PREF_KEY_BACKUP_PASSWORD = "backupLegacyPassword" +private const val PREF_KEY_BACKUPS_SCHEDULED = "backupsScheduled" + +fun setBackupFolderUri(context: Context, uri: Uri) { + getDefaultSharedPreferences(context) + .edit() + .putString(PREF_KEY_BACKUP_URI, uri.toString()) + .apply() +} + +fun getBackupFolderUri(context: Context): Uri? { + val uriStr = getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_URI, null) + ?: return null + return Uri.parse(uriStr) +} + +/** + * This is insecure and not supposed to be part of a release, + * but rather an intermediate step towards a generated passphrase. + */ +@Deprecated("Replaced by KeyManager#storeBackupKey()") +fun setBackupPassword(context: Context, password: String) { + getDefaultSharedPreferences(context) + .edit() + .putString(PREF_KEY_BACKUP_PASSWORD, password) + .apply() +} + +@Deprecated("Replaced by KeyManager#getBackupKey()") +fun getBackupPassword(context: Context): String? { + return getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_PASSWORD, null) +} + +fun setBackupsScheduled(context: Context) { + getDefaultSharedPreferences(context) + .edit() + .putBoolean(PREF_KEY_BACKUPS_SCHEDULED, true) + .apply() +} + +fun areBackupsScheduled(context: Context): Boolean { + return getDefaultSharedPreferences(context).getBoolean(PREF_KEY_BACKUPS_SCHEDULED, false) +} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt new file mode 100644 index 00000000..dd20a14a --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt @@ -0,0 +1,52 @@ +package com.stevesoltys.backup.settings + +import android.app.Application +import android.content.Intent +import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION +import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION +import androidx.lifecycle.AndroidViewModel +import com.stevesoltys.backup.LiveEvent +import com.stevesoltys.backup.MutableLiveEvent +import com.stevesoltys.backup.security.KeyManager +import com.stevesoltys.backup.service.backup.requestFullBackup + +private val TAG = SettingsViewModel::class.java.name + +class SettingsViewModel(application: Application) : AndroidViewModel(application) { + + private val app = application + + private val locationWasSet = MutableLiveEvent() + /** + * Will be set to true if this is the initial location. + * It will be false if an existing location was changed. + */ + internal val onLocationSet: LiveEvent = locationWasSet + + private val mChooseBackupLocation = MutableLiveEvent() + internal val chooseBackupLocation: LiveEvent = mChooseBackupLocation + internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true) + + fun recoveryCodeIsSet() = KeyManager.hasBackupKey() + fun locationIsSet() = getBackupFolderUri(getApplication()) != null + + fun handleChooseFolderResult(result: Intent?) { + val folderUri = result?.data ?: return + + // persist permission to access backup folder across reboots + val takeFlags = result.flags and (FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION) + app.contentResolver.takePersistableUriPermission(folderUri, takeFlags) + + // check if this is initial set-up or a later change + val wasEmptyBefore = getBackupFolderUri(app) == null + + // store backup folder location in settings + setBackupFolderUri(app, folderUri) + + // notify the UI that the location has been set + locationWasSet.setEvent(wasEmptyBefore) + } + + fun backupNow() = Thread { requestFullBackup(app) }.start() + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java index b709bb13..522a6a14 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java +++ b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java @@ -4,11 +4,13 @@ import android.app.backup.BackupTransport; import android.app.backup.RestoreDescription; import android.app.backup.RestoreSet; import android.content.Context; +import android.content.Intent; import android.content.pm.PackageInfo; import android.net.Uri; import android.os.ParcelFileDescriptor; import android.util.Log; +import com.stevesoltys.backup.settings.SettingsActivity; import com.stevesoltys.backup.transport.component.BackupComponent; import com.stevesoltys.backup.transport.component.RestoreComponent; import com.stevesoltys.backup.transport.component.provider.ContentProviderBackupComponent; @@ -27,11 +29,14 @@ public class ConfigurableBackupTransport extends BackupTransport { private static final String TAG = TRANSPORT_DIRECTORY_NAME; + private final Context context; + private final BackupComponent backupComponent; private final RestoreComponent restoreComponent; ConfigurableBackupTransport(Context context) { + this.context = context; backupComponent = new ContentProviderBackupComponent(context); restoreComponent = new ContentProviderRestoreComponent(context); } @@ -57,6 +62,11 @@ public class ConfigurableBackupTransport extends BackupTransport { return 0; } + @Override + public Intent dataManagementIntent() { + return new Intent(context, SettingsActivity.class); + } + @Override public boolean isAppEligibleForBackup(PackageInfo targetPackage, boolean isFullBackup) { return true; diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java index bab06921..a951d50a 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java +++ b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java @@ -10,7 +10,6 @@ import android.util.Log; import com.stevesoltys.backup.security.CipherUtil; import com.stevesoltys.backup.security.KeyGenerator; -import com.stevesoltys.backup.settings.SettingsManager; import com.stevesoltys.backup.transport.component.BackupComponent; import org.apache.commons.io.IOUtils; @@ -42,6 +41,8 @@ import static android.provider.DocumentsContract.buildDocumentUriUsingTree; import static android.provider.DocumentsContract.createDocument; import static android.provider.DocumentsContract.getTreeDocumentId; import static com.stevesoltys.backup.activity.MainActivityController.DOCUMENT_MIME_TYPE; +import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupFolderUri; +import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword; import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_BACKUP_QUOTA; import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY; import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_INCREMENTAL_BACKUP_DIRECTORY; @@ -286,13 +287,13 @@ public class ContentProviderBackupComponent implements BackupComponent { backupState.getOutputStream().write(backupState.getSalt()); backupState.getOutputStream().closeEntry(); - String password = requireNonNull(SettingsManager.getBackupPassword(context)); + String password = requireNonNull(getBackupPassword(context)); backupState.setSecretKey(KeyGenerator.generate(password, backupState.getSalt())); } } private void initializeOutputStream() throws IOException { - Uri folderUri = SettingsManager.getBackupFolderUri(context); + Uri folderUri = getBackupFolderUri(context); // TODO notify about failure with notification Uri fileUri = createBackupFile(folderUri); diff --git a/app/src/main/res/drawable/ic_cloud_upload.xml b/app/src/main/res/drawable/ic_cloud_upload.xml new file mode 100644 index 00000000..aa601bab --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud_upload.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_outline.xml b/app/src/main/res/drawable/ic_info_outline.xml new file mode 100644 index 00000000..1a849802 --- /dev/null +++ b/app/src/main/res/drawable/ic_info_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_storage.xml b/app/src/main/res/drawable/ic_storage.xml new file mode 100644 index 00000000..713f2caf --- /dev/null +++ b/app/src/main/res/drawable/ic_storage.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_recovery_code.xml b/app/src/main/res/layout/activity_recovery_code.xml new file mode 100644 index 00000000..d64f58e8 --- /dev/null +++ b/app/src/main/res/layout/activity_recovery_code.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 00000000..d64f58e8 --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_recovery_code_input.xml b/app/src/main/res/layout/fragment_recovery_code_input.xml new file mode 100644 index 00000000..1442389a --- /dev/null +++ b/app/src/main/res/layout/fragment_recovery_code_input.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + +