diff --git a/README.md b/README.md index af466d89..2eeb6e5e 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,12 @@ The backlog is currently empty. ## Current development -### Version 3.16 -- [ ] Send csv export via Mail +### Version 3.17 - [ ] Move actions into Book item (https://github.com/florent37/ExpansionPanel) - [ ] Add Onboarding + optional Login +- [ ] Backup file improvements + - [ ] Show path to local backup files + - [ ] Open file with FileProvider - [ ] Experimental remote storage Firestore implementation (for test account) - [ ] Fix bug with local book covers - [ ] In MultiBareBoneBookView (not showing up) @@ -70,6 +72,24 @@ The backlog is currently empty. ## Changelog +### Version 3.16 - BRING BACK BACKUP +* Google Drive REST Backup + * Add DriveRestClient + * Remove DriveApiClient + dependencies + * Move dependencies in `BookStorageModule` + * Polish Overwrite/Merge UI + * Replace Google Drive icon +* Foundation for exporting local backups via Mail/other services + * Open local backups via intent chooser + * Allow external backup import in Import tab + * Change export/import icon + * Track open backup event (with provider) +* Replace ActionBar in BookManagement screen +* Backup issues + * Fix Backup Proguard rules + * Make last backup time reactive + * Fix issues with CSV files on emulators + ### Version 3.15 * Statistics pages/books over time / month + Goal per month * Fix issue where MarkerView draws out of ChartView bounds @@ -124,7 +144,7 @@ The backlog is currently empty. ### Version 3.6 - GET EXCITED * Move to Android App Bundles -* Improve Backups +* Improve Backups * Open source Dante ### Version 3.5 - ADD ANDROID FRAMEWORK AWESOMENESS @@ -194,9 +214,9 @@ The backlog is currently empty. * Search feature ### Version 2.6 - DETAILED DESIGN -* Rate books +* Rate books * 100% Kotlin Port if possible -* Enter book page count manually +* Enter book page count manually * Adding notes to books ### Version 2.5 - REFACTOR RAMPAGE @@ -209,6 +229,6 @@ The backlog is currently empty. * Add Crashlytics * Code cleanup and Kotlin Port * Introduction / Showcase View -* DownloadBook / QueryCapture Activity merging +* DownloadBook / QueryCapture Activity merging * ViewPagerAdapter * Adaptive Icons \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index f36f5120..94f0b1e0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,14 +6,14 @@ apply plugin: 'com.google.firebase.crashlytics' android { compileSdkVersion 30 - buildToolsVersion '29.0.3' + buildToolsVersion '30.0.0' defaultConfig { applicationId "at.shockbytes.dante" minSdkVersion 21 targetSdkVersion 30 - versionCode 37 - versionName "3.15" + versionCode 38 + versionName "3.16" multiDexEnabled true vectorDrawables.useSupportLibrary = true @@ -56,6 +56,14 @@ android { } packagingOptions { exclude 'META-INF/util_release.kotlin_module' + exclude 'META-INF/DEPENDENCIES' + exclude 'META-INF/LICENSE' + exclude 'META-INF/LICENSE.txt' + exclude 'META-INF/license.txt' + exclude 'META-INF/NOTICE' + exclude 'META-INF/NOTICE.txt' + exclude 'META-INF/notice.txt' + exclude 'META-INF/ASL2.0' } kotlinOptions { jvmTarget = "1.8" @@ -82,7 +90,7 @@ dependencies { implementation 'androidx.fragment:fragment-ktx:1.2.5' implementation 'androidx.recyclerview:recyclerview:1.1.0' - implementation 'androidx.constraintlayout:constraintlayout:2.0.2' + implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.core:core-ktx:1.3.2' @@ -90,7 +98,6 @@ dependencies { implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$archXVersion" implementation "androidx.lifecycle:lifecycle-common-java8:$archXVersion" implementation "com.google.android.gms:play-services-vision:$playServicesVersionVision" - implementation "com.google.android.gms:play-services-drive:$playServicesVersionDrive" implementation "com.google.android.gms:play-services-auth:$playServicesVersionAuth" implementation "com.google.firebase:firebase-crashlytics:$firebaseCrashlyticsVersion" implementation "com.google.firebase:firebase-core:$firebaseVersionCore" @@ -98,6 +105,17 @@ dependencies { implementation "com.google.firebase:firebase-auth:$firebaseVersionAuth" implementation "com.google.firebase:firebase-ml-vision:$firebaseMLVision" + + implementation ('com.google.http-client:google-http-client-gson:1.36.0') { + exclude group: 'org.apache.httpcomponents' + } + implementation('com.google.api-client:google-api-client-android:1.30.11') { + exclude group: 'org.apache.httpcomponents' + } + implementation('com.google.apis:google-api-services-drive:v3-rev197-1.25.0') { + exclude group: 'org.apache.httpcomponents' + } + implementation "com.google.dagger:dagger:$daggerVersion" kapt "com.google.dagger:dagger-compiler:$daggerVersion" @@ -116,11 +134,11 @@ dependencies { implementation 'uk.co.samuelwall:material-tap-target-prompt:2.14.0' // Test libraries - testImplementation 'junit:junit:4.12' + testImplementation 'junit:junit:4.13.1' testImplementation 'androidx.test:core:1.3.0' testImplementation 'org.mockito:mockito-core:2.28.2' testImplementation "com.google.truth:truth:1.0.1" - testImplementation 'joda-time:joda-time:2.10.6' + testImplementation 'joda-time:joda-time:2.10.7' androidTestImplementation 'androidx.test:core:1.3.0' androidTestImplementation 'org.mockito:mockito-android:2.28.2' diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 5a70ee6c..351743e9 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -23,23 +23,27 @@ # - Retrofit # - Realm # - Gson -# - Butterknife # - Jackson # - GMS # - Crashlytics # - Timber +# - Jackson # - Firebase -# - SpeedDialView +# Activity Transition -keep public class android.app.ActivityTransitionCoordinator -# Never obfuscate model classes --keepclassmembers class at.shockbytes.book.** { - *; +# Do not obfuscate backup models +-keepclassmembers class at.shockbytes.dante.backup.model.* { + ; + (); + ; } - --keepclassmembers class at.shockbytes.dante.backup.model.** { - *; +-keep class at.shockbytes.dante.core.book.** {*;} +-keepclassmembers class at.shockbytes.dante.core.book.* { + ; + (); + ; } -keepclassmembers class * extends java.lang.Enum { @@ -70,7 +74,6 @@ -keep @io.realm.internal.Keep class * -dontwarn javax.** -dontwarn io.realm.** --keep class at.shockbytes.util.books.** { *; } # Retrofit -dontnote retrofit2.Platform @@ -78,14 +81,6 @@ -keepattributes Signature -keepattributes Exceptions -# Gson --keepclassmembers enum * { *; } - -# ButterKnife --keep class butterknife.* --keepclasseswithmembernames class * { @butterknife.* ; } --keepclasseswithmembernames class * { @butterknife.* ; } - # Jackson -keep class com.fasterxml.jackson.databind.ObjectMapper { public ; @@ -97,6 +92,10 @@ -keepnames class com.fasterxml.jackson.** { *; } -dontwarn com.fasterxml.jackson.databind.** +# Gson +-keepclassmembers enum * { *; } +-keep class com.google.** { *;} + # gms -keep class com.google.android.gms.** { *; } -dontwarn com.google.android.gms.** @@ -109,7 +108,4 @@ -dontwarn com.crashlytics.** # Timber --dontwarn org.jetbrains.annotations.** - -# Activity Transition --keep public class android.app.ActivityTransitionCoordinator \ No newline at end of file +-dontwarn org.jetbrains.annotations.** \ No newline at end of file diff --git a/app/src/androidTest/java/at/shockbytes/dante/backup/ExternalStorageBackupProviderTest.kt b/app/src/androidTest/java/at/shockbytes/dante/backup/ExternalStorageBackupProviderTest.kt index 0a5e6660..fcc4da3d 100644 --- a/app/src/androidTest/java/at/shockbytes/dante/backup/ExternalStorageBackupProviderTest.kt +++ b/app/src/androidTest/java/at/shockbytes/dante/backup/ExternalStorageBackupProviderTest.kt @@ -1,5 +1,6 @@ package at.shockbytes.dante.backup +import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import at.shockbytes.dante.backup.model.BackupServiceConnectionException @@ -14,18 +15,17 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito.mock import at.shockbytes.dante.ui.activity.MainActivity -import androidx.test.rule.ActivityTestRule import at.shockbytes.dante.backup.model.BackupItem import at.shockbytes.dante.backup.model.BackupMetadata import at.shockbytes.dante.backup.model.BackupMetadataState import at.shockbytes.dante.backup.model.BackupStorageProvider import at.shockbytes.dante.core.book.BookEntity +import at.shockbytes.dante.importer.DanteExternalStorageImportProvider import at.shockbytes.dante.util.permission.TestPermissionManager import at.shockbytes.test.ObjectCreator import at.shockbytes.test.TestResourceManager import at.shockbytes.test.any import io.reactivex.Single -import org.junit.Rule import org.mockito.Mockito.`when` import java.io.File @@ -40,9 +40,9 @@ class ExternalStorageBackupProviderTest { private lateinit var backupProvider: ExternalStorageBackupProvider private val externalStorageInteractor = mock(ExternalStorageInteractor::class.java) + private val externalStorageImporter = DanteExternalStorageImportProvider(Gson()) - @get:Rule - var activityRule = ActivityTestRule(MainActivity::class.java) + private val activityScenario: ActivityScenario = ActivityScenario.launch(MainActivity::class.java) @Before fun setup() { @@ -50,7 +50,8 @@ class ExternalStorageBackupProviderTest { schedulerFacade, gson, externalStorageInteractor, - permissionManager + permissionManager, + externalStorageImporter ) } @@ -71,23 +72,27 @@ class ExternalStorageBackupProviderTest { `when`(externalStorageInteractor.createBaseDirectory("Dante")) .thenThrow(IllegalStateException::class.java) - backupProvider - .initialize(activityRule.activity) - .test() - .assertError(IllegalStateException::class.java) + activityScenario.onActivity { activity -> + backupProvider + .initialize(activity) + .test() + .assertError(IllegalStateException::class.java) - assertThat(backupProvider.isEnabled).isFalse() + assertThat(backupProvider.isEnabled).isFalse() + } } @Test fun test_initialize_working() { - backupProvider - .initialize(activityRule.activity) - .test() - .assertComplete() + activityScenario.onActivity { activity -> + backupProvider + .initialize(activity) + .test() + .assertComplete() - assertThat(backupProvider.isEnabled).isTrue() + assertThat(backupProvider.isEnabled).isTrue() + } } @Test @@ -95,7 +100,7 @@ class ExternalStorageBackupProviderTest { val books = listOf() - backupProvider.backup(books) + backupProvider.backup() .test() .assertComplete() } @@ -105,7 +110,7 @@ class ExternalStorageBackupProviderTest { val books = ObjectCreator.getPopulatedListOfBookEntities() - backupProvider.backup(books) + backupProvider.backup() .test() .assertComplete() } @@ -118,7 +123,7 @@ class ExternalStorageBackupProviderTest { val books = ObjectCreator.getPopulatedListOfBookEntities() - backupProvider.backup(books) + backupProvider.backup() .test() .assertNotComplete() .assertError(IllegalStateException::class.java) @@ -130,7 +135,7 @@ class ExternalStorageBackupProviderTest { val file1 = File("entry_1.dbi") val file2 = File("entry_2.dbi") - val metadata = gson.fromJson>(TestResourceManager.getTestResourceAsString(javaClass, "/backup_entries.json")) + val metadata = gson.fromJson>(TestResourceManager.getTestResourceAsString(javaClass, "/backup_entries.json")) val expected = metadata.map { BackupMetadataState.Active(it) } val backupItem1 = BackupItem(metadata[0], ObjectCreator.getPopulatedListOfBookEntities()) @@ -170,7 +175,7 @@ class ExternalStorageBackupProviderTest { val file1 = File("entry_1.dbi") val file2 = File("entry_2.dbi") - val metadata = gson.fromJson>(TestResourceManager.getTestResourceAsString(javaClass, "/backup_entries.json")) + val metadata = gson.fromJson>(TestResourceManager.getTestResourceAsString(javaClass, "/backup_entries.json")) val expected = listOf(metadata.map { BackupMetadataState.Active(it) }.first()) val backupItem1 = BackupItem(metadata[0], ObjectCreator.getPopulatedListOfBookEntities()) @@ -197,13 +202,13 @@ class ExternalStorageBackupProviderTest { @Test fun test_mapEntryToBooks() { - val metadata = BackupMetadata( + val metadata = BackupMetadata.Standard( id = "12345", device = "Nokia 7.1", storageProvider = BackupStorageProvider.EXTERNAL_STORAGE, books = 3, timestamp = System.currentTimeMillis(), - fileName = "test_mapEntryToBooks${BackupRepository.BACKUP_ITEM_SUFFIX}" + fileName = "test_mapEntryToBooks.json" ) val expected = ObjectCreator.getPopulatedListOfBookEntities() @@ -212,7 +217,7 @@ class ExternalStorageBackupProviderTest { `when`(externalStorageInteractor.readFileContent("Dante", metadata.fileName)) .thenReturn(gson.toJson(backupItem)) - backupProvider.mapEntryToBooks(metadata) + backupProvider.mapBackupToBackupContent(metadata) .test() .assertValue(expected) } @@ -220,13 +225,13 @@ class ExternalStorageBackupProviderTest { @Test fun test_mapEntryToBooks_corrupt_json() { - val metadata = BackupMetadata( + val metadata = BackupMetadata.Standard( id = "12345", device = "Nokia 7.1", storageProvider = BackupStorageProvider.EXTERNAL_STORAGE, books = 3, timestamp = System.currentTimeMillis(), - fileName = "test_mapEntryToBooks${BackupRepository.BACKUP_ITEM_SUFFIX}" + fileName = "test_mapEntryToBooks.json" ) val expected = ObjectCreator.getPopulatedListOfBookEntities() @@ -236,7 +241,7 @@ class ExternalStorageBackupProviderTest { `when`(externalStorageInteractor.readFileContent("Dante", metadata.fileName)) .thenReturn(corruptJson) - backupProvider.mapEntryToBooks(metadata) + backupProvider.mapBackupToBackupContent(metadata) .test() .assertError(NullPointerException::class.java) } diff --git a/app/src/main/java/at/shockbytes/dante/DanteApp.kt b/app/src/main/java/at/shockbytes/dante/DanteApp.kt index 09a356f7..d60b29ed 100644 --- a/app/src/main/java/at/shockbytes/dante/DanteApp.kt +++ b/app/src/main/java/at/shockbytes/dante/DanteApp.kt @@ -11,6 +11,7 @@ import at.shockbytes.dante.core.injection.NetworkModule import at.shockbytes.dante.injection.AppComponent import at.shockbytes.dante.injection.AppModule import at.shockbytes.dante.injection.AppNetworkModule +import at.shockbytes.dante.injection.BookStorageModule import at.shockbytes.dante.injection.DaggerAppComponent import at.shockbytes.dante.injection.FirebaseModule import at.shockbytes.dante.util.CrashlyticsReportingTree @@ -65,6 +66,7 @@ class DanteApp : MultiDexApplication(), CoreComponentProvider { .appNetworkModule(AppNetworkModule()) .appModule(AppModule(this)) .firebaseModule(FirebaseModule(this)) + .bookStorageModule(BookStorageModule(this)) .coreComponent(provideCoreComponent()) .build() .also { component -> diff --git a/app/src/main/java/at/shockbytes/dante/announcement/Announcement.kt b/app/src/main/java/at/shockbytes/dante/announcement/Announcement.kt index 4413d47f..bf4b6556 100644 --- a/app/src/main/java/at/shockbytes/dante/announcement/Announcement.kt +++ b/app/src/main/java/at/shockbytes/dante/announcement/Announcement.kt @@ -1,7 +1,10 @@ package at.shockbytes.dante.announcement +import androidx.annotation.DrawableRes import androidx.annotation.RawRes import androidx.annotation.StringRes +import at.shockbytes.dante.R +import at.shockbytes.dante.navigation.Destination data class Announcement( val key: String, @@ -11,17 +14,38 @@ data class Announcement( val action: Action? ) { + val hasAction: Boolean + get() = action != null + sealed class Illustration { data class LottieIllustration( @RawRes val lottieRes: Int ) : Illustration() + + data class ImageIllustration( + @DrawableRes val drawableRes: Int + ) : Illustration() } sealed class Action { - data class OpenUrl(val url: String) : Action() + @get:StringRes + abstract val actionLabel: Int + + data class OpenUrl( + @StringRes override val actionLabel: Int = R.string.open, + val url: String + ) : Action() + + data class Mail( + @StringRes override val actionLabel: Int = R.string.action_send_mail, + val subject: Int + ) : Action() - data class Mail(val subject: Int) : Action() + data class OpenScreen( + @StringRes override val actionLabel: Int = R.string.go_to_screen, + val destination: Destination + ) : Action() } } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/announcement/SharedPrefsAnnouncementProvider.kt b/app/src/main/java/at/shockbytes/dante/announcement/SharedPrefsAnnouncementProvider.kt index 6a220f1f..ee50a530 100644 --- a/app/src/main/java/at/shockbytes/dante/announcement/SharedPrefsAnnouncementProvider.kt +++ b/app/src/main/java/at/shockbytes/dante/announcement/SharedPrefsAnnouncementProvider.kt @@ -1,12 +1,20 @@ package at.shockbytes.dante.announcement import android.content.SharedPreferences +import at.shockbytes.dante.R +import at.shockbytes.dante.navigation.Destination class SharedPrefsAnnouncementProvider( private val sharedPreferences: SharedPreferences ) : AnnouncementProvider { - private val activeAnnouncement: Announcement? = null + private val activeAnnouncement: Announcement? = Announcement( + key = "fixed_backup_announcement", + titleRes = R.string.announcement_fixed_backup_title, + descriptionRes = R.string.announcement_fixed_backup_description, + illustration = Announcement.Illustration.ImageIllustration(R.drawable.ic_google_drive), + action = Announcement.Action.OpenScreen(destination = Destination.BookStorage) + ) override fun getActiveAnnouncement(): Announcement? { diff --git a/app/src/main/java/at/shockbytes/dante/backup/BackupContentTransform.kt b/app/src/main/java/at/shockbytes/dante/backup/BackupContentTransform.kt new file mode 100644 index 00000000..1f6f420d --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/backup/BackupContentTransform.kt @@ -0,0 +1,54 @@ +package at.shockbytes.dante.backup + +import android.os.Build +import at.shockbytes.dante.backup.model.BackupContent +import at.shockbytes.dante.backup.model.BackupData +import at.shockbytes.dante.backup.model.BackupItem +import at.shockbytes.dante.backup.model.BackupMetadata +import at.shockbytes.dante.backup.model.BackupStorageProvider +import at.shockbytes.dante.util.singleOf +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import io.reactivex.Single + +class BackupContentTransform( + private val backupStorageProvider: BackupStorageProvider, + private val fileNameSupplier: (timestamp: Long, books: Int) -> String +) { + + private val gson: Gson = Gson() + + fun createActualBackupData(backupContent: BackupContent): Single { + return singleOf { + val timestamp = System.currentTimeMillis() + val fileName = fileNameSupplier(timestamp, backupContent.books.size) + val metadata = bundleMetadataForStorage(backupContent.books.size, fileName, timestamp) + + val item = BackupItem(metadata, backupContent.books, backupContent.records) + val content = gson.toJson(item) + + BackupData(fileName, content) + } + } + + private fun bundleMetadataForStorage( + books: Int, + fileName: String, + timestamp: Long + ): BackupMetadata.Standard { + return BackupMetadata.Standard( + id = fileName, + fileName = fileName, + timestamp = timestamp, + books = books, + storageProvider = backupStorageProvider, + device = Build.MODEL + ) + } + + fun createBackupContentFromBackupData(content: String): Single { + return singleOf { + gson.fromJson(content, object : TypeToken() {}.type) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/backup/BackupRepository.kt b/app/src/main/java/at/shockbytes/dante/backup/BackupRepository.kt index 8b15fa65..243f9473 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/BackupRepository.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/BackupRepository.kt @@ -1,14 +1,16 @@ package at.shockbytes.dante.backup import androidx.fragment.app.FragmentActivity +import at.shockbytes.dante.backup.model.BackupContent import at.shockbytes.dante.backup.model.BackupMetadata import at.shockbytes.dante.backup.model.BackupMetadataState import at.shockbytes.dante.backup.model.BackupStorageProvider import at.shockbytes.dante.util.RestoreStrategy import at.shockbytes.dante.backup.provider.BackupProvider -import at.shockbytes.dante.core.book.BookEntity import at.shockbytes.dante.core.data.BookRepository +import at.shockbytes.dante.core.data.PageRecordDao import io.reactivex.Completable +import io.reactivex.Observable import io.reactivex.Single /** @@ -19,7 +21,9 @@ interface BackupRepository { val backupProvider: List - var lastBackupTime: Long + fun setLastBackupTime(timeInMillis: Long) + + fun observeLastBackupTime(): Observable fun getBackups(): Single> @@ -31,17 +35,15 @@ interface BackupRepository { fun removeAllBackupEntries(): Completable - fun backup(books: List, backupStorageProvider: BackupStorageProvider): Completable + fun backup( + backupContent: BackupContent, + backupStorageProvider: BackupStorageProvider + ): Completable fun restoreBackup( entry: BackupMetadata, bookRepository: BookRepository, + pageRecordDao: PageRecordDao, strategy: RestoreStrategy ): Completable - - companion object { - const val KEY_LAST_BACKUP = "key_last_backup" - - const val BACKUP_ITEM_SUFFIX = ".dbi" - } } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/backup/DefaultBackupRepository.kt b/app/src/main/java/at/shockbytes/dante/backup/DefaultBackupRepository.kt index 80f609a8..6a9f30c7 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/DefaultBackupRepository.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/DefaultBackupRepository.kt @@ -2,6 +2,7 @@ package at.shockbytes.dante.backup import android.content.SharedPreferences import androidx.fragment.app.FragmentActivity +import at.shockbytes.dante.backup.model.BackupContent import at.shockbytes.dante.backup.model.BackupMetadata import at.shockbytes.dante.backup.model.BackupMetadataState import at.shockbytes.dante.backup.model.BackupStorageProvider @@ -9,27 +10,43 @@ import at.shockbytes.dante.util.RestoreStrategy import at.shockbytes.dante.backup.provider.BackupProvider import at.shockbytes.dante.backup.model.BackupStorageProviderNotAvailableException import at.shockbytes.dante.core.book.BookEntity +import at.shockbytes.dante.core.book.PageRecord import at.shockbytes.dante.core.data.BookRepository -import at.shockbytes.dante.util.settings.delegate.SharedPreferencesLongPropertyDelegate +import at.shockbytes.dante.core.data.PageRecordDao +import at.shockbytes.dante.util.settings.delegate.edit import at.shockbytes.tracking.Tracker import at.shockbytes.tracking.event.DanteTrackingEvent +import com.f2prateek.rx.preferences2.RxSharedPreferences import io.reactivex.Completable +import io.reactivex.Observable import io.reactivex.Single class DefaultBackupRepository( override val backupProvider: List, - preferences: SharedPreferences, + private val preferences: SharedPreferences, private val tracker: Tracker ) : BackupRepository { private val activeBackupProvider: List get() = backupProvider.filter { it.isEnabled } - override var lastBackupTime: Long by SharedPreferencesLongPropertyDelegate(preferences, BackupRepository.KEY_LAST_BACKUP, 0) + override fun setLastBackupTime(timeInMillis: Long) { + preferences.edit { + putLong(KEY_LAST_BACKUP, timeInMillis) + } + } + + override fun observeLastBackupTime(): Observable { + return RxSharedPreferences.create(preferences) + .getLong(KEY_LAST_BACKUP, 0) + .asObservable() + } override fun getBackups(): Single> { - return Single.merge(activeBackupProvider.map { it.getBackupEntries() }) + val activeBackupProviderSources = activeBackupProvider.map { it.getBackupEntries() } + + return Single.merge(activeBackupProviderSources) .collect( { mutableListOf() }, { container: MutableList, value: List -> @@ -47,7 +64,7 @@ class DefaultBackupRepository( override fun initialize(activity: FragmentActivity, forceReload: Boolean): Completable { - // If forceReload is set, then use the whole list of backup provider, + // If forceReload is set, then use the whole listBackupFiles of backup provider, // otherwise just use the active ones val provider = if (forceReload) backupProvider else activeBackupProvider @@ -59,37 +76,86 @@ class DefaultBackupRepository( } override fun removeBackupEntry(entry: BackupMetadata): Completable { - return getBackupProvider(entry.storageProvider)?.removeBackupEntry(entry) - ?: Completable.error(BackupStorageProviderNotAvailableException()) + return getBackupProvider(entry.storageProvider).removeBackupEntry(entry) } override fun removeAllBackupEntries(): Completable { return Completable.concat(activeBackupProvider.map { it.removeAllBackupEntries() }) } - override fun backup(books: List, backupStorageProvider: BackupStorageProvider): Completable { + override fun backup( + backupContent: BackupContent, + backupStorageProvider: BackupStorageProvider + ): Completable { return getBackupProvider(backupStorageProvider) - ?.backup(books) - ?.doOnComplete { - lastBackupTime = System.currentTimeMillis() - tracker.track(DanteTrackingEvent.BackupMadeEvent(backupStorageProvider.acronym)) + .backup(backupContent) + .doOnComplete { + setLastBackupTime(System.currentTimeMillis()) + trackBackupMadeEvent(backupStorageProvider) } ?: Completable.error(BackupStorageProviderNotAvailableException()) } + private fun trackBackupMadeEvent(backupStorageProvider: BackupStorageProvider) { + tracker.track(DanteTrackingEvent.BackupMadeEvent(backupStorageProvider.acronym)) + } + override fun restoreBackup( entry: BackupMetadata, bookRepository: BookRepository, + pageRecordDao: PageRecordDao, strategy: RestoreStrategy ): Completable { - - return getBackupProvider(entry.storageProvider)?.mapEntryToBooks(entry) - ?.flatMapCompletable { books -> + return getBackupProvider(entry.storageProvider) + .mapBackupToBackupContent(entry) + .flatMapCompletable { (books, pageRecords) -> + val copyOfBooks = books.map { it.copy() } bookRepository.restoreBackup(books, strategy) - } ?: Completable.error(BackupStorageProviderNotAvailableException()) + .andThen(restorePageRecords(bookRepository, pageRecordDao, books = copyOfBooks, pageRecords, strategy)) + } + } + + private fun restorePageRecords( + bookRepository: BookRepository, + pageRecordDao: PageRecordDao, + books: List, + pageRecords: List, + strategy: RestoreStrategy + ): Completable { + return bookRepository.bookObservable + .firstOrError() + .map { restoredBooks -> + val map = createIdMappingForRestoredBooks(restoredBooks, books) + pageRecords.map { pageRecord -> + pageRecord.copy(bookId = map[pageRecord.bookId] + ?: error("Cannot find previously restored book by map lookup!")) + } + } + .flatMapCompletable { mappedPageRecords -> + pageRecordDao.restoreBackup(mappedPageRecords, strategy) + } } - private fun getBackupProvider(source: BackupStorageProvider): BackupProvider? { + private fun createIdMappingForRestoredBooks( + restoredBooks: List, + backupBooks: List + ): Map { + return restoredBooks.associate { book -> + + val backupBookId = backupBooks.find { + book.title == it.title && book.author == it.author + }?.id ?: throw IllegalStateException("Cannot find previously restored book by title lookup!") + + backupBookId to book.id + } + } + + private fun getBackupProvider(source: BackupStorageProvider): BackupProvider { return activeBackupProvider.find { it.backupStorageProvider == source } + ?: throw BackupStorageProviderNotAvailableException() + } + + companion object { + private const val KEY_LAST_BACKUP = "key_last_backup" } } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/backup/model/BackupContent.kt b/app/src/main/java/at/shockbytes/dante/backup/model/BackupContent.kt new file mode 100644 index 00000000..8823205f --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/backup/model/BackupContent.kt @@ -0,0 +1,12 @@ +package at.shockbytes.dante.backup.model + +import at.shockbytes.dante.core.book.BookEntity +import at.shockbytes.dante.core.book.PageRecord + +data class BackupContent( + val books: List, + val records: List +) { + val isEmpty: Boolean + get() = books.isEmpty() +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/backup/model/BackupData.kt b/app/src/main/java/at/shockbytes/dante/backup/model/BackupData.kt new file mode 100644 index 00000000..2a11c89e --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/backup/model/BackupData.kt @@ -0,0 +1,9 @@ +package at.shockbytes.dante.backup.model + +/** + * Actual [content] that is written into the backup file with the given [fileName]. + */ +data class BackupData( + val fileName: String, + val content: String +) \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/backup/model/BackupException.kt b/app/src/main/java/at/shockbytes/dante/backup/model/BackupException.kt index 6fbc85b4..21b1c4f6 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/model/BackupException.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/model/BackupException.kt @@ -4,4 +4,4 @@ package at.shockbytes.dante.backup.model * Author: Martin Macheiner * Date: 01.05.2017 */ -class BackupException(s: String) : Throwable(s) +class BackupException(s: String, val fileName: String? = null) : Throwable(s) diff --git a/app/src/main/java/at/shockbytes/dante/backup/model/BackupItem.kt b/app/src/main/java/at/shockbytes/dante/backup/model/BackupItem.kt index 1a75eb5e..ac650068 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/model/BackupItem.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/model/BackupItem.kt @@ -1,14 +1,16 @@ package at.shockbytes.dante.backup.model import at.shockbytes.dante.core.book.BookEntity +import at.shockbytes.dante.core.book.PageRecord /** - * BackupItem holds both the metadata and the actual list of books + * BackupItem holds both the metadata and the actual listBackupFiles of books * * Author: Martin Macheiner * Date: 29.05.2019 */ data class BackupItem( - val backupMetadata: BackupMetadata, - val books: List + val backupMetadata: BackupMetadata.Standard, + val books: List, + val records: List ) \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/backup/model/BackupMetadata.kt b/app/src/main/java/at/shockbytes/dante/backup/model/BackupMetadata.kt index c8979277..1b18dca1 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/model/BackupMetadata.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/model/BackupMetadata.kt @@ -1,14 +1,53 @@ package at.shockbytes.dante.backup.model +import java.io.File + /** * Author: Martin Macheiner * Date: 30.04.2017 */ -data class BackupMetadata( - val id: String, - val fileName: String, - val device: String, - val storageProvider: BackupStorageProvider, - val books: Int, - val timestamp: Long -) +sealed class BackupMetadata { + + abstract val id: String + abstract val fileName: String + abstract val device: String + abstract val storageProvider: BackupStorageProvider + abstract val books: Int + abstract val timestamp: Long + + data class Standard( + override val id: String, + override val fileName: String, + override val device: String, + override val storageProvider: BackupStorageProvider, + override val books: Int, + override val timestamp: Long + ) : BackupMetadata() + + data class WithLocalFile( + override val id: String, + override val fileName: String, + override val device: String, + override val storageProvider: BackupStorageProvider, + override val books: Int, + override val timestamp: Long, + val localFilePath: File, + val mimeType: String + ) : BackupMetadata() + + companion object { + + fun Standard.attachLocalFile(localFile: File, mimeType: String): WithLocalFile { + return WithLocalFile( + id = this.id, + fileName = this.fileName, + device = this.device, + storageProvider = this.storageProvider, + books = this.books, + timestamp = this.timestamp, + localFilePath = localFile, + mimeType = mimeType + ) + } + } +} diff --git a/app/src/main/java/at/shockbytes/dante/backup/model/BackupMetadataState.kt b/app/src/main/java/at/shockbytes/dante/backup/model/BackupMetadataState.kt index 8c23b281..5f1ab104 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/model/BackupMetadataState.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/model/BackupMetadataState.kt @@ -7,7 +7,7 @@ sealed class BackupMetadataState { val timestamp: Long get() = entry.timestamp - val isFileDownloadable: Boolean + val isFileExportable: Boolean get() = entry.storageProvider.isLocalFileExportable data class Active(override val entry: BackupMetadata) : BackupMetadataState() diff --git a/app/src/main/java/at/shockbytes/dante/backup/model/BackupStorageProvider.kt b/app/src/main/java/at/shockbytes/dante/backup/model/BackupStorageProvider.kt index d4a5a4ad..a40787f8 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/model/BackupStorageProvider.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/model/BackupStorageProvider.kt @@ -37,15 +37,15 @@ enum class BackupStorageProvider( isLocalFileExportable = false, stability = Stability.CANARY ), - @SerializedName("gdrive") + @SerializedName("google-drive") GOOGLE_DRIVE( - "gdrive", + "google-drive", "Google Drive", R.drawable.ic_google_drive, R.string.backup_storage_provider_rationale_gdrive, Priority.HIGH, isLocalFileExportable = false, - stability = Stability.DISCONTINUED + stability = Stability.RELEASE ), @SerializedName("ext_storage") EXTERNAL_STORAGE( diff --git a/app/src/main/java/at/shockbytes/dante/backup/provider/BackupProvider.kt b/app/src/main/java/at/shockbytes/dante/backup/provider/BackupProvider.kt index d5c39eaa..c0255a4d 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/provider/BackupProvider.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/provider/BackupProvider.kt @@ -1,10 +1,10 @@ package at.shockbytes.dante.backup.provider import androidx.fragment.app.FragmentActivity +import at.shockbytes.dante.backup.model.BackupContent import at.shockbytes.dante.backup.model.BackupMetadata import at.shockbytes.dante.backup.model.BackupMetadataState import at.shockbytes.dante.backup.model.BackupStorageProvider -import at.shockbytes.dante.core.book.BookEntity import io.reactivex.Completable import io.reactivex.Single @@ -16,7 +16,7 @@ interface BackupProvider { fun initialize(activity: FragmentActivity? = null): Completable - fun backup(books: List): Completable + fun backup(backupContent: BackupContent): Completable fun getBackupEntries(): Single> @@ -24,7 +24,7 @@ interface BackupProvider { fun removeAllBackupEntries(): Completable - fun mapEntryToBooks(entry: BackupMetadata): Single> + fun mapBackupToBackupContent(entry: BackupMetadata): Single fun teardown(): Completable } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/backup/provider/csv/LocalCsvBackupProvider.kt b/app/src/main/java/at/shockbytes/dante/backup/provider/csv/LocalCsvBackupProvider.kt index 59bed5aa..18bf9a61 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/provider/csv/LocalCsvBackupProvider.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/provider/csv/LocalCsvBackupProvider.kt @@ -4,6 +4,7 @@ import android.Manifest import android.os.Build import androidx.fragment.app.FragmentActivity import at.shockbytes.dante.R +import at.shockbytes.dante.backup.model.BackupContent import at.shockbytes.dante.backup.model.BackupMetadata import at.shockbytes.dante.backup.model.BackupMetadataState import at.shockbytes.dante.backup.model.BackupServiceConnectionException @@ -15,6 +16,7 @@ import at.shockbytes.dante.importer.DanteCsvImportProvider import at.shockbytes.dante.storage.ExternalStorageInteractor import at.shockbytes.dante.util.permission.PermissionManager import at.shockbytes.dante.util.scheduler.SchedulerFacade +import at.shockbytes.dante.util.singleOf import io.reactivex.Completable import io.reactivex.Single import timber.log.Timber @@ -54,24 +56,25 @@ class LocalCsvBackupProvider( } } - override fun backup(books: List): Completable { - return getBackupContent(books) + override fun backup(backupContent: BackupContent): Completable { + return createBackupDataFromBackupContent(backupContent.books) .flatMapCompletable { (fileName, content) -> externalStorageInteractor.writeToFileInDirectory(BASE_DIR_NAME, fileName, content) } .subscribeOn(schedulers.io) } - /** - * Returns Pair - */ - private fun getBackupContent(books: List): Single> { + private data class BackupFileContent(val fileName: String, val content: String) + + private fun createBackupDataFromBackupContent( + books: List + ): Single { return Single.fromCallable { val timestamp = System.currentTimeMillis() val fileName = createFileName(timestamp, books.size) val content = createContent(books) - Pair(fileName, content) + BackupFileContent(fileName, content) } } @@ -138,20 +141,21 @@ class LocalCsvBackupProvider( return try { val fileName = backupFile.name - Timber.i("File name of backup file: $fileName") val data = fileName.split("_".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() val storageProvider = BackupStorageProvider.byAcronym(data[1]) val timestamp = data[2].toLong() val books = Integer.parseInt(data[3]) - val device = data[4].substring(0, data[4].lastIndexOf(".")) + val device = fileName.substring(fileName.indexOf(data[4]), fileName.lastIndexOf(".")) - val metadata = BackupMetadata( + val metadata = BackupMetadata.WithLocalFile( id = fileName, fileName = fileName, device = device, storageProvider = storageProvider, books = books, - timestamp = timestamp + timestamp = timestamp, + localFilePath = backupFile, + mimeType = CSV_MIME_TYPE ) // Can only be active, ExternalStorageBackupProvider does not support cached states @@ -174,12 +178,15 @@ class LocalCsvBackupProvider( .subscribeOn(schedulers.io) } - override fun mapEntryToBooks(entry: BackupMetadata): Single> { - return Single - .fromCallable { + override fun mapBackupToBackupContent(entry: BackupMetadata): Single { + return singleOf { externalStorageInteractor.readFileContent(BASE_DIR_NAME, entry.fileName) } .flatMap(csvImporter::importFromContent) + .map { books -> + // Page records are not supported by this backup provider + BackupContent(books, listOf()) + } .subscribeOn(schedulers.io) } @@ -219,6 +226,7 @@ class LocalCsvBackupProvider( companion object { + private const val CSV_MIME_TYPE = "text/csv" private const val CSV_SUFFIX = ".csv" private const val BASE_DIR_NAME = "Dante" private const val RC_READ_WRITE_EXT_STORAGE = 0x5321 diff --git a/app/src/main/java/at/shockbytes/dante/backup/provider/external/ExternalStorageBackupProvider.kt b/app/src/main/java/at/shockbytes/dante/backup/provider/external/ExternalStorageBackupProvider.kt index 8cfbf9fb..cdea27bb 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/provider/external/ExternalStorageBackupProvider.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/provider/external/ExternalStorageBackupProvider.kt @@ -1,20 +1,21 @@ package at.shockbytes.dante.backup.provider.external import android.Manifest -import android.os.Build import androidx.fragment.app.FragmentActivity import at.shockbytes.dante.R -import at.shockbytes.dante.backup.BackupRepository +import at.shockbytes.dante.backup.BackupContentTransform +import at.shockbytes.dante.backup.model.BackupContent import at.shockbytes.dante.backup.model.BackupItem import at.shockbytes.dante.backup.model.BackupMetadata +import at.shockbytes.dante.backup.model.BackupMetadata.Companion.attachLocalFile import at.shockbytes.dante.backup.model.BackupMetadataState import at.shockbytes.dante.backup.model.BackupServiceConnectionException import at.shockbytes.dante.backup.model.BackupStorageProvider import at.shockbytes.dante.backup.provider.BackupProvider -import at.shockbytes.dante.core.book.BookEntity import at.shockbytes.dante.storage.ExternalStorageInteractor import at.shockbytes.dante.util.permission.PermissionManager import at.shockbytes.dante.util.scheduler.SchedulerFacade +import at.shockbytes.dante.util.singleOf import com.google.gson.Gson import io.reactivex.Completable import io.reactivex.Single @@ -35,6 +36,8 @@ class ExternalStorageBackupProvider( override val backupStorageProvider = BackupStorageProvider.EXTERNAL_STORAGE override var isEnabled: Boolean = true + private val contentTransform = BackupContentTransform(backupStorageProvider, ::createFileName) + override fun initialize(activity: FragmentActivity?): Completable { return Completable.fromAction { @@ -59,35 +62,20 @@ class ExternalStorageBackupProvider( } } - override fun backup(books: List): Completable { - return getBackupContent(books) + override fun backup(backupContent: BackupContent): Completable { + return contentTransform.createActualBackupData(backupContent) .flatMapCompletable { (fileName, content) -> externalStorageInteractor.writeToFileInDirectory(BASE_DIR_NAME, fileName, content) } .subscribeOn(schedulers.io) } - /** - * Returns Pair - */ - private fun getBackupContent(books: List): Single> { - return Single.fromCallable { - val timestamp = System.currentTimeMillis() - val fileName = createFileName(timestamp) - val metadata = getMetadata(books.size, fileName, timestamp) - - val content = gson.toJson(BackupItem(metadata, books)) - - Pair(fileName, content) - } - } - override fun getBackupEntries(): Single> { return externalStorageInteractor .listFilesInDirectory( BASE_DIR_NAME, filterPredicate = { fileName -> - fileName.endsWith(BackupRepository.BACKUP_ITEM_SUFFIX) + fileName.endsWith(BACKUP_ITEM_SUFFIX) } ).map { files -> files.mapNotNull { backupFile -> @@ -101,13 +89,16 @@ class ExternalStorageBackupProvider( return try { - val metadata = externalStorageInteractor - .readFileContent( - BASE_DIR_NAME, - backupFile.name - ).let { content -> - gson.fromJson(content, BackupItem::class.java).backupMetadata - } + val metadata = externalStorageInteractor.readFileContent( + BASE_DIR_NAME, + backupFile.name + ).let { content -> + gson.fromJson(content, BackupItem::class.java) + .backupMetadata + // This line is necessary because the local file path is not stored + // within the serialized Json, it's only used when loaded + .attachLocalFile(backupFile, mimeType = MIME_TYPE_JSON) + } // Can only be active, ExternalStorageBackupProvider does not support cached states BackupMetadataState.Active(metadata) @@ -129,13 +120,11 @@ class ExternalStorageBackupProvider( .subscribeOn(schedulers.io) } - override fun mapEntryToBooks(entry: BackupMetadata): Single> { - return Single - .fromCallable { - externalStorageInteractor.readFileContent(BASE_DIR_NAME, entry.fileName).let { content -> - gson.fromJson(content, BackupItem::class.java).books - } + override fun mapBackupToBackupContent(entry: BackupMetadata): Single { + return singleOf { + externalStorageInteractor.readFileContent(BASE_DIR_NAME, entry.fileName) } + .flatMap(contentTransform::createBackupContentFromBackupData) .subscribeOn(schedulers.io) } @@ -164,24 +153,15 @@ class ExternalStorageBackupProvider( } } - private fun getMetadata(books: Int, fileName: String, timestamp: Long): BackupMetadata { - return BackupMetadata( - id = fileName, - fileName = fileName, - timestamp = timestamp, - books = books, - storageProvider = backupStorageProvider, - device = Build.MODEL - ) - } - - private fun createFileName(timestamp: Long): String { - return "dante-backup-$timestamp${BackupRepository.BACKUP_ITEM_SUFFIX}" + private fun createFileName(timestamp: Long, books: Int): String { + return "dante-backup-$timestamp$BACKUP_ITEM_SUFFIX" } companion object { private const val BASE_DIR_NAME = "Dante" + private const val BACKUP_ITEM_SUFFIX = ".json" + private const val MIME_TYPE_JSON = "application/json" private const val RC_READ_WRITE_EXT_STORAGE = 0x5321 private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE) diff --git a/app/src/main/java/at/shockbytes/dante/backup/provider/google/DriveClient.kt b/app/src/main/java/at/shockbytes/dante/backup/provider/google/DriveClient.kt new file mode 100644 index 00000000..1b8903f3 --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/backup/provider/google/DriveClient.kt @@ -0,0 +1,21 @@ +package at.shockbytes.dante.backup.provider.google + +import androidx.fragment.app.FragmentActivity +import at.shockbytes.dante.backup.model.BackupMetadata +import io.reactivex.Completable +import io.reactivex.Single + +interface DriveClient { + + fun initialize(activity: FragmentActivity): Completable + + fun readFileAsString(fileId: String): Single + + fun createFile(filename: String, content: String): Completable + + fun deleteFile(fileId: String, fileName: String): Completable + + fun deleteListedFiles(): Completable + + fun listBackupFiles(): Single> +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/backup/provider/google/DriveRestClient.kt b/app/src/main/java/at/shockbytes/dante/backup/provider/google/DriveRestClient.kt new file mode 100644 index 00000000..2fcfe063 --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/backup/provider/google/DriveRestClient.kt @@ -0,0 +1,180 @@ +package at.shockbytes.dante.backup.provider.google + +import androidx.fragment.app.FragmentActivity +import at.shockbytes.dante.backup.model.BackupMetadata +import at.shockbytes.dante.backup.model.BackupStorageProvider +import at.shockbytes.dante.signin.GoogleFirebaseSignInManager +import at.shockbytes.dante.util.completableOf +import at.shockbytes.dante.util.merge +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.File +import io.reactivex.Completable +import io.reactivex.Single +import timber.log.Timber +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.lang.Exception +import java.util.Collections +import java.util.concurrent.Executors + +/** + * TODO Explain [selectedStorageProvider] necessity! + */ +class DriveRestClient( + private val signInManager: GoogleFirebaseSignInManager, + private val selectedStorageProvider: BackupStorageProvider = BackupStorageProvider.GOOGLE_DRIVE +) : DriveClient { + + private lateinit var drive: Drive + + private val executor = Executors.newSingleThreadExecutor() + + override fun initialize(activity: FragmentActivity): Completable { + return completableOf { + // Use the authenticated account to sign in to the Drive service. + val credential: GoogleAccountCredential = GoogleAccountCredential + .usingOAuth2(activity, Collections.singleton(DriveScopes.DRIVE_FILE)) + .apply { + selectedAccount = signInManager.getGoogleAccount()!!.account // Fail here if null + } + + drive = Drive + .Builder( + NetHttpTransport(), + GsonFactory(), + credential + ) + .setApplicationName(APP_NAME) + .build() + } + } + + override fun readFileAsString(fileId: String): Single { + return Single.create { emitter -> + + Tasks.call(executor) { + val metadata: File = drive.files().get(fileId).execute() + val name: String = metadata.name + log("Read filename: $name") + + try { + drive.files().get(fileId).executeMediaAsInputStream().use { inputStream -> + BufferedReader(InputStreamReader(inputStream)) + .use { it.readText() } + .also(::log) + .let(emitter::onSuccess) + } + } catch (exception: Exception) { + Timber.e(exception) + emitter.tryOnError(exception) + } + } + } + } + + override fun createFile(filename: String, content: String): Completable { + return Completable.create { emitter -> + Tasks.call(executor) { + + try { + val metadata: File = File() + .setParents(Collections.singletonList(PARENT_FOLDER)) + .setMimeType(MIME_TYPE) + .setName(filename) + + val contentStream = ByteArrayContent.fromString(MIME_TYPE, content) + + log("Create file with content $content") + + val file = drive.files().create(metadata, contentStream).execute() + ?: throw IOException("Null result when requesting file creation.") + + log("File created with ID: <${file.id}>") + + emitter.onComplete() + } catch (e: Exception) { + emitter.tryOnError(e) + } + } + } + } + + override fun deleteFile(fileId: String, fileName: String): Completable { + return completableOf { + drive.files().delete(fileId).execute() + } + } + + override fun deleteListedFiles(): Completable { + return listBackupFiles() + .flatMapCompletable { backupMetadataSet -> + backupMetadataSet + .map { file -> + deleteFile(file.id, file.fileName) + } + .merge() + } + } + + override fun listBackupFiles(): Single> { + return Single.create { emitter -> + Tasks.call(executor) { + + try { + drive.files().list().setSpaces(SPACES) + .execute() + .files + .mapNotNull { file -> + + val fileName = file.name + val data = fileName.split("_".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val storageProvider = BackupStorageProvider.byAcronym(data[0]) + + if (storageProvider == selectedStorageProvider) { + log(file.toPrettyString()) + + val device = fileName.substring( + fileName.indexOf(data[4]), + fileName.lastIndexOf(".") + ) + + val timestamp = data[2].toLong() + val books = data[3].toInt() + + BackupMetadata.Standard( + id = file.id, + fileName = fileName, + device = device, + storageProvider = storageProvider, + books = books, + timestamp = timestamp + ) + } else null + } + .let(emitter::onSuccess) + } catch (e: Exception) { + emitter.tryOnError(e) + } + } + } + } + + private fun log(msg: String) { + Timber.d(msg) + } + + companion object { + + private const val APP_NAME = "Dante" + private const val MIME_TYPE = "application/json" + private const val PARENT_FOLDER = "appDataFolder" + private const val SPACES = "appDataFolder" + } +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/backup/provider/google/GoogleDriveBackupProvider.kt b/app/src/main/java/at/shockbytes/dante/backup/provider/google/GoogleDriveBackupProvider.kt index e8e05299..566d3eb6 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/provider/google/GoogleDriveBackupProvider.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/provider/google/GoogleDriveBackupProvider.kt @@ -2,104 +2,73 @@ package at.shockbytes.dante.backup.provider.google import android.os.Build import androidx.fragment.app.FragmentActivity +import at.shockbytes.dante.backup.BackupContentTransform +import at.shockbytes.dante.backup.model.BackupContent import at.shockbytes.dante.backup.model.BackupMetadata import at.shockbytes.dante.backup.model.BackupMetadataState import at.shockbytes.dante.backup.model.BackupException import at.shockbytes.dante.backup.model.BackupServiceConnectionException import at.shockbytes.dante.backup.model.BackupStorageProvider import at.shockbytes.dante.backup.provider.BackupProvider -import at.shockbytes.dante.core.book.BookEntity -import at.shockbytes.dante.signin.GoogleFirebaseSignInManager import at.shockbytes.dante.util.scheduler.SchedulerFacade -import com.google.android.gms.drive.Drive -import com.google.android.gms.drive.DriveFile -import com.google.android.gms.drive.DriveId -import com.google.android.gms.drive.DriveResourceClient -import com.google.android.gms.drive.MetadataBuffer -import com.google.android.gms.drive.MetadataChangeSet -import com.google.android.gms.tasks.Tasks -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import io.reactivex.Completable import io.reactivex.Single import timber.log.Timber -import java.io.BufferedReader -import java.io.BufferedWriter -import java.io.IOException -import java.io.InputStreamReader -import java.io.OutputStreamWriter class GoogleDriveBackupProvider( - private val signInManager: GoogleFirebaseSignInManager, private val schedulers: SchedulerFacade, - private val gson: Gson + private val driveClient: DriveClient ) : BackupProvider { - private lateinit var client: DriveResourceClient - override var isEnabled: Boolean = true override val backupStorageProvider = BackupStorageProvider.GOOGLE_DRIVE - override fun mapEntryToBooks(entry: BackupMetadata): Single> { - return Single - .fromCallable { - - val file = DriveId.decodeFromString(entry.id).asDriveFile() - val result = client.openFile(file, DriveFile.MODE_READ_ONLY) - - val contents = Tasks.await(result) - val reader = BufferedReader(InputStreamReader(contents?.inputStream)) - val builder = StringBuilder() + private val contentTransform = BackupContentTransform(backupStorageProvider, ::createFilename) - for (line in reader.lineSequence()) { - builder.append(line) - } - val contentsAsString = builder.toString() - client.discardContents(contents) // Close contents - - val list: List = gson.fromJson(contentsAsString, - object : TypeToken>() {}.type) - list + override fun mapBackupToBackupContent(entry: BackupMetadata): Single { + return driveClient.readFileAsString(entry.id) + .map { content -> + content // TODO Remove later } + .flatMap(contentTransform::createBackupContentFromBackupData) .subscribeOn(schedulers.io) .observeOn(schedulers.ui) } - override fun backup(books: List): Completable { + override fun backup(backupContent: BackupContent): Completable { - if (books.isEmpty()) { + if (backupContent.isEmpty) { return Completable.error(BackupException("No books to backup")) } - val content = gson.toJson(books) - val filename = createFilename(books.size) - - return createFile(filename, content) + return contentTransform.createActualBackupData(backupContent) + .flatMapCompletable { (filename, content) -> + driveClient.createFile(filename, content) + } .subscribeOn(schedulers.io) .observeOn(schedulers.ui) } + private fun createFilename(timestamp: Long, books: Int): String { + val type = "man" + return backupStorageProvider.acronym + "_" + + type + "_" + + timestamp + "_" + + books + "_" + + Build.MODEL + ".json" + } + override fun initialize(activity: FragmentActivity?): Completable { if (activity == null) { isEnabled = false - throw BackupServiceConnectionException("This backup provider requires an activity!") + throw BackupServiceConnectionException("This backup provider requires an attached activity!") } - return Completable.fromAction { - - val initializedClient = signInManager.getGoogleAccount()?.let { account -> - Drive.getDriveResourceClient(activity, account) - } - - if (initializedClient != null) { - isEnabled = true - client = initializedClient - } else { - isEnabled = false - } - } + return driveClient.initialize(activity) + .doOnError { isEnabled = false } + .doOnComplete { isEnabled = true } } override fun teardown(): Completable { @@ -107,13 +76,7 @@ class GoogleDriveBackupProvider( } override fun getBackupEntries(): Single> { - return Single - .fromCallable { - client.appFolder?.let { folder -> - val appFolder = Tasks.await(folder) - fromMetadataToBackupEntries(Tasks.await(client.listChildren(appFolder))) - } ?: listOf() - } + return driveClient.listBackupFiles() .map { entries -> entries .mapTo(mutableListOf()) { BackupMetadataState.Active(it) } @@ -128,112 +91,14 @@ class GoogleDriveBackupProvider( } override fun removeBackupEntry(entry: BackupMetadata): Completable { - return Completable - .fromAction { - if (!deleteDriveFile(DriveId.decodeFromString(entry.id))) { - Completable.error(Throwable(BackupException("Cannot delete backup entry: " + entry.fileName))) - } - } + return driveClient.deleteFile(entry.id, entry.fileName) .subscribeOn(schedulers.io) .observeOn(schedulers.ui) } override fun removeAllBackupEntries(): Completable { - return Completable - .fromAction { - val appFolder = Tasks.await(client.appFolder) - Tasks.await(client.listChildren(appFolder)) - .all { deleteDriveFile(it.driveId) } - .let { allDeleted -> - if (!allDeleted) { - throw BackupException("Cannot remove all backup entries!") - } - } - } + return driveClient.deleteListedFiles() .subscribeOn(schedulers.io) .observeOn(schedulers.ui) } - - // ----------------------------------------------------------------------------- - - private fun deleteDriveFile(driveId: DriveId): Boolean { - return client.delete(driveId.asDriveResource())?.isSuccessful ?: false - } - - private fun fromMetadataToBackupEntries(result: MetadataBuffer?): List { - - val entries = result?.mapNotNullTo(mutableListOf()) { buffer -> - - val fileId = buffer.driveId.encodeToString() - val fileName = buffer.title - try { - - Timber.i("File name of backup file: $fileName") - val data = fileName.split("_".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - val storageProviderAcronym = data[0] - val storageProvider = BackupStorageProvider.byAcronym(storageProviderAcronym) - val device = data[4].substring(0, data[4].lastIndexOf(".")) - val timestamp = java.lang.Long.parseLong(data[2]) - val books = Integer.parseInt(data[3]) - - BackupMetadata( - id = fileId, - fileName = fileName, - device = device, - storageProvider = storageProvider, - books = books, - timestamp = timestamp - ) - } catch (e: Exception) { - Timber.e(e, "Cannot parse file: $fileName") - null - } - } - - result?.release() - return entries ?: listOf() - } - - private fun createFilename(books: Int): String { - - val timestamp = System.currentTimeMillis() - val type = "man" - - return backupStorageProvider.acronym + "_" + - type + "_" + - timestamp + "_" + - books + "_" + - Build.MODEL + ".json" - } - - private fun createFile(filename: String, content: String): Completable { - return Completable.fromAction { - val changeSet = MetadataChangeSet.Builder() - .setTitle(filename) - .setMimeType("application/json") - .build() - - val driveContents = Tasks.await(client.createContents()) - - val out = driveContents?.outputStream - val writer = BufferedWriter(OutputStreamWriter(out)) - - try { - writer.write(content) - } catch (e: IOException) { - e.printStackTrace() - throw e - } finally { - try { - writer.close() - } catch (e: IOException) { - e.printStackTrace() - } - } - - val driveFileResult = Tasks.await(client.appFolder) - Tasks.await(client.createFile(driveFileResult, changeSet, driveContents)) - ?: throw NullPointerException("Result of backup creation is null!") - } - } } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/backup/provider/shockbytes/ShockbytesHerokuServerBackupProvider.kt b/app/src/main/java/at/shockbytes/dante/backup/provider/shockbytes/ShockbytesHerokuServerBackupProvider.kt index 96f706be..08abd551 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/provider/shockbytes/ShockbytesHerokuServerBackupProvider.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/provider/shockbytes/ShockbytesHerokuServerBackupProvider.kt @@ -1,14 +1,14 @@ package at.shockbytes.dante.backup.provider.shockbytes import androidx.fragment.app.FragmentActivity +import at.shockbytes.dante.backup.model.BackupContent import at.shockbytes.dante.backup.model.BackupMetadata import at.shockbytes.dante.backup.model.BackupMetadataState import at.shockbytes.dante.backup.model.BackupStorageProvider import at.shockbytes.dante.backup.provider.BackupProvider import at.shockbytes.dante.backup.provider.shockbytes.api.ShockbytesHerokuApi import at.shockbytes.dante.backup.provider.shockbytes.storage.InactiveShockbytesBackupStorage -import at.shockbytes.dante.core.book.BookEntity -import at.shockbytes.dante.signin.GoogleFirebaseSignInManager +import at.shockbytes.dante.signin.SignInManager import io.reactivex.Completable import io.reactivex.Single import io.reactivex.schedulers.Schedulers @@ -19,7 +19,7 @@ import timber.log.Timber * Date: 09.05.2019 */ class ShockbytesHerokuServerBackupProvider( - private val signInManager: GoogleFirebaseSignInManager, + private val signInManager: SignInManager, private val shockbytesHerokuApi: ShockbytesHerokuApi, private val inactiveBackupStorage: InactiveShockbytesBackupStorage ) : BackupProvider { @@ -34,10 +34,10 @@ class ShockbytesHerokuServerBackupProvider( return Completable.complete() } - override fun backup(books: List): Completable { + override fun backup(backupContent: BackupContent): Completable { return signInManager.getAuthorizationHeader() .flatMapCompletable { token -> - shockbytesHerokuApi.makeBackup(token, books) + shockbytesHerokuApi.makeBackup(token, backupContent) .flatMapCompletable { entry -> Timber.d(entry.toString()) // TODO What to do with entry? Store in UI? @@ -79,7 +79,7 @@ class ShockbytesHerokuServerBackupProvider( .flatMapCompletable(shockbytesHerokuApi::removeAllBackups) } - override fun mapEntryToBooks(entry: BackupMetadata): Single> { + override fun mapBackupToBackupContent(entry: BackupMetadata): Single { return signInManager.getAuthorizationHeader() .flatMap { token -> shockbytesHerokuApi diff --git a/app/src/main/java/at/shockbytes/dante/backup/provider/shockbytes/api/ShockbytesHerokuApi.kt b/app/src/main/java/at/shockbytes/dante/backup/provider/shockbytes/api/ShockbytesHerokuApi.kt index 0dbfda6c..1c68d7ef 100644 --- a/app/src/main/java/at/shockbytes/dante/backup/provider/shockbytes/api/ShockbytesHerokuApi.kt +++ b/app/src/main/java/at/shockbytes/dante/backup/provider/shockbytes/api/ShockbytesHerokuApi.kt @@ -1,7 +1,7 @@ package at.shockbytes.dante.backup.provider.shockbytes.api +import at.shockbytes.dante.backup.model.BackupContent import at.shockbytes.dante.backup.model.BackupMetadata -import at.shockbytes.dante.core.book.BookEntity import io.reactivex.Completable import io.reactivex.Single import retrofit2.http.Body @@ -37,7 +37,7 @@ interface ShockbytesHerokuApi { fun getBooksBackupById( @Header("Authorization") bearerToken: String, @Path("backupId") backupId: String - ): Single> + ): Single @DELETE("backup/{backupId}") fun removeBackupById( @@ -48,7 +48,7 @@ interface ShockbytesHerokuApi { @PUT("backup") fun makeBackup( @Header("Authorization") bearerToken: String, - @Body books: List + @Body backupContent: BackupContent ): Single companion object { diff --git a/app/src/main/java/at/shockbytes/dante/importer/DanteCsvImportProvider.kt b/app/src/main/java/at/shockbytes/dante/importer/DanteCsvImportProvider.kt index bd79c2f9..a2c64f51 100644 --- a/app/src/main/java/at/shockbytes/dante/importer/DanteCsvImportProvider.kt +++ b/app/src/main/java/at/shockbytes/dante/importer/DanteCsvImportProvider.kt @@ -25,7 +25,7 @@ class DanteCsvImportProvider( .mapNotNull(::createBookEntityFromLine) .toList() } else { - // Return a list of empty books and indicate that no books could be imported + // Return a listBackupFiles of empty books and indicate that no books could be imported // NOTE: This can be vastly improved by returning an exception listOf() } diff --git a/app/src/main/java/at/shockbytes/dante/importer/DanteExternalStorageImportProvider.kt b/app/src/main/java/at/shockbytes/dante/importer/DanteExternalStorageImportProvider.kt new file mode 100644 index 00000000..b0c2dabb --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/importer/DanteExternalStorageImportProvider.kt @@ -0,0 +1,18 @@ +package at.shockbytes.dante.importer + +import at.shockbytes.dante.backup.model.BackupItem +import at.shockbytes.dante.core.book.BookEntity +import at.shockbytes.dante.util.singleOf +import com.google.gson.Gson +import io.reactivex.Single + +class DanteExternalStorageImportProvider(private val gson: Gson) : ImportProvider { + + override val importer: Importer = Importer.DANTE_EXTERNAL_STORAGE + + override fun importFromContent(content: String): Single> { + return singleOf { + gson.fromJson(content, BackupItem::class.java).books + } + } +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/importer/GoodreadsCsvImportProvider.kt b/app/src/main/java/at/shockbytes/dante/importer/GoodreadsCsvImportProvider.kt index efc9b3ca..eb28315e 100644 --- a/app/src/main/java/at/shockbytes/dante/importer/GoodreadsCsvImportProvider.kt +++ b/app/src/main/java/at/shockbytes/dante/importer/GoodreadsCsvImportProvider.kt @@ -27,7 +27,7 @@ class GoodreadsCsvImportProvider( .mapNotNull(::createBookEntityFromLine) .toList() } else { - // Return a list of empty books and indicate that no books could be imported + // Return a listBackupFiles of empty books and indicate that no books could be imported // NOTE: This can be vastly improved by returning an exception listOf() } diff --git a/app/src/main/java/at/shockbytes/dante/importer/Importer.kt b/app/src/main/java/at/shockbytes/dante/importer/Importer.kt index 06b7dc84..dadbf2d7 100644 --- a/app/src/main/java/at/shockbytes/dante/importer/Importer.kt +++ b/app/src/main/java/at/shockbytes/dante/importer/Importer.kt @@ -13,6 +13,20 @@ enum class Importer( val stability: Stability ) { + DANTE_EXTERNAL_STORAGE( + R.string.import_dante_external_title, + R.drawable.ic_external_storage, + R.string.import_external_storage_description, + mimeType = "application/json", + stability = Stability.RELEASE + ), + DANTE_CSV( + R.string.import_dante_csv_title, + R.drawable.ic_csv, + R.string.import_dante_description, + mimeType = "text/csv", + stability = Stability.BETA + ), GOODREADS_CSV( R.string.import_goodreads_title, R.drawable.ic_import_goodreads, @@ -20,11 +34,4 @@ enum class Importer( mimeType = "text/csv", stability = Stability.BETA ), - DANTE_CSV( - R.string.app_name, - R.drawable.ic_brand_app_logo, - R.string.import_dante_description, - mimeType = "text/csv", - stability = Stability.BETA - ) } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/injection/AppComponent.kt b/app/src/main/java/at/shockbytes/dante/injection/AppComponent.kt index a227d6d6..06747d75 100644 --- a/app/src/main/java/at/shockbytes/dante/injection/AppComponent.kt +++ b/app/src/main/java/at/shockbytes/dante/injection/AppComponent.kt @@ -50,7 +50,8 @@ import dagger.Component (AppModule::class), (AppNetworkModule::class), (ViewModelModule::class), - (FirebaseModule::class) + (FirebaseModule::class), + (BookStorageModule::class) ], dependencies = [CoreComponent::class] ) diff --git a/app/src/main/java/at/shockbytes/dante/injection/AppModule.kt b/app/src/main/java/at/shockbytes/dante/injection/AppModule.kt index 722a92e2..8a86e332 100644 --- a/app/src/main/java/at/shockbytes/dante/injection/AppModule.kt +++ b/app/src/main/java/at/shockbytes/dante/injection/AppModule.kt @@ -7,37 +7,16 @@ import androidx.preference.PreferenceManager import at.shockbytes.dante.BuildConfig import at.shockbytes.dante.announcement.AnnouncementProvider import at.shockbytes.dante.announcement.SharedPrefsAnnouncementProvider -import at.shockbytes.dante.backup.BackupRepository -import at.shockbytes.dante.backup.DefaultBackupRepository -import at.shockbytes.dante.backup.provider.BackupProvider -import at.shockbytes.dante.backup.provider.csv.LocalCsvBackupProvider -import at.shockbytes.dante.storage.DefaultExternalStorageInteractor -import at.shockbytes.dante.backup.provider.external.ExternalStorageBackupProvider -import at.shockbytes.dante.storage.ExternalStorageInteractor -import at.shockbytes.dante.backup.provider.google.GoogleDriveBackupProvider -import at.shockbytes.dante.backup.provider.shockbytes.ShockbytesHerokuServerBackupProvider -import at.shockbytes.dante.backup.provider.shockbytes.api.ShockbytesHerokuApi -import at.shockbytes.dante.backup.provider.shockbytes.storage.InactiveShockbytesBackupStorage -import at.shockbytes.dante.backup.provider.shockbytes.storage.SharedPreferencesInactiveShockbytesBackupStorage -import at.shockbytes.dante.core.data.BookRepository import at.shockbytes.dante.signin.GoogleFirebaseSignInManager import at.shockbytes.dante.signin.SignInManager import at.shockbytes.dante.util.settings.DanteSettings import at.shockbytes.dante.flagging.FeatureFlagging import at.shockbytes.dante.flagging.FirebaseFeatureFlagging import at.shockbytes.dante.flagging.SharedPreferencesFeatureFlagging -import at.shockbytes.dante.importer.DanteCsvImportProvider -import at.shockbytes.dante.importer.DefaultImportRepository -import at.shockbytes.dante.importer.GoodreadsCsvImportProvider -import at.shockbytes.dante.importer.ImportProvider -import at.shockbytes.dante.importer.ImportRepository -import at.shockbytes.dante.storage.reader.CsvReader import at.shockbytes.dante.util.permission.AndroidPermissionManager import at.shockbytes.dante.util.permission.PermissionManager import at.shockbytes.dante.util.scheduler.SchedulerFacade -import at.shockbytes.tracking.Tracker import com.google.firebase.remoteconfig.FirebaseRemoteConfig -import com.google.gson.Gson import dagger.Module import dagger.Provides @@ -61,68 +40,11 @@ class AppModule(private val app: Application) { return DanteSettings(app.applicationContext, sharedPreferences, schedulers) } - @Provides - fun provideInactiveShockbytesBackupStorage( - preferences: SharedPreferences - ): InactiveShockbytesBackupStorage { - return SharedPreferencesInactiveShockbytesBackupStorage(preferences) - } - - @Provides - fun provideBackupRepository( - backupProvider: Array, - preferences: SharedPreferences, - tracker: Tracker - ): BackupRepository { - return DefaultBackupRepository(backupProvider.toList(), preferences, tracker) - } - - @Provides - fun provideExternalStorageInteractor(): ExternalStorageInteractor { - return DefaultExternalStorageInteractor(app.applicationContext) - } - @Provides fun providePermissionManager(): PermissionManager { return AndroidPermissionManager() } - @Provides - fun provideBackupProvider( - schedulerFacade: SchedulerFacade, - signInManager: SignInManager, - shockbytesHerokuApi: ShockbytesHerokuApi, - inactiveShockbytesBackupStorage: InactiveShockbytesBackupStorage, - externalStorageInteractor: ExternalStorageInteractor, - permissionManager: PermissionManager, - csvImportProvider: DanteCsvImportProvider - ): Array { - return arrayOf( - GoogleDriveBackupProvider( - signInManager as GoogleFirebaseSignInManager, - schedulerFacade, - Gson() - ), - ShockbytesHerokuServerBackupProvider( - signInManager, - shockbytesHerokuApi, - inactiveShockbytesBackupStorage - ), - ExternalStorageBackupProvider( - schedulerFacade, - Gson(), - externalStorageInteractor, - permissionManager - ), - LocalCsvBackupProvider( - schedulerFacade, - externalStorageInteractor, - permissionManager, - csvImportProvider - ) - ) - } - @Provides fun provideGoogleSignInManager( prefs: SharedPreferences, @@ -146,29 +68,4 @@ class AppModule(private val app: Application) { val prefs = app.getSharedPreferences("announcements", Context.MODE_PRIVATE) return SharedPrefsAnnouncementProvider(prefs) } - - @Provides - fun provideDanteCsvImportProvider(schedulers: SchedulerFacade): DanteCsvImportProvider { - return DanteCsvImportProvider(CsvReader(), schedulers) - } - - @Provides - fun provideImportProvider( - schedulers: SchedulerFacade, - danteCsvImportProvider: DanteCsvImportProvider - ): Array { - return arrayOf( - GoodreadsCsvImportProvider(CsvReader(), schedulers), - danteCsvImportProvider - ) - } - - @Provides - fun provideImportRepository( - importProvider: Array, - bookRepository: BookRepository, - schedulers: SchedulerFacade - ): ImportRepository { - return DefaultImportRepository(importProvider, bookRepository, schedulers) - } } diff --git a/app/src/main/java/at/shockbytes/dante/injection/BookStorageModule.kt b/app/src/main/java/at/shockbytes/dante/injection/BookStorageModule.kt new file mode 100644 index 00000000..56708c92 --- /dev/null +++ b/app/src/main/java/at/shockbytes/dante/injection/BookStorageModule.kt @@ -0,0 +1,132 @@ +package at.shockbytes.dante.injection + +import android.app.Application +import android.content.SharedPreferences +import at.shockbytes.dante.backup.BackupRepository +import at.shockbytes.dante.backup.DefaultBackupRepository +import at.shockbytes.dante.backup.provider.BackupProvider +import at.shockbytes.dante.backup.provider.csv.LocalCsvBackupProvider +import at.shockbytes.dante.backup.provider.external.ExternalStorageBackupProvider +import at.shockbytes.dante.backup.provider.google.DriveClient +import at.shockbytes.dante.backup.provider.google.DriveRestClient +import at.shockbytes.dante.backup.provider.google.GoogleDriveBackupProvider +import at.shockbytes.dante.backup.provider.shockbytes.ShockbytesHerokuServerBackupProvider +import at.shockbytes.dante.backup.provider.shockbytes.api.ShockbytesHerokuApi +import at.shockbytes.dante.backup.provider.shockbytes.storage.InactiveShockbytesBackupStorage +import at.shockbytes.dante.backup.provider.shockbytes.storage.SharedPreferencesInactiveShockbytesBackupStorage +import at.shockbytes.dante.core.data.BookRepository +import at.shockbytes.dante.importer.DanteCsvImportProvider +import at.shockbytes.dante.importer.DanteExternalStorageImportProvider +import at.shockbytes.dante.importer.DefaultImportRepository +import at.shockbytes.dante.importer.GoodreadsCsvImportProvider +import at.shockbytes.dante.importer.ImportProvider +import at.shockbytes.dante.importer.ImportRepository +import at.shockbytes.dante.signin.GoogleFirebaseSignInManager +import at.shockbytes.dante.signin.SignInManager +import at.shockbytes.dante.storage.DefaultExternalStorageInteractor +import at.shockbytes.dante.storage.ExternalStorageInteractor +import at.shockbytes.dante.storage.reader.CsvReader +import at.shockbytes.dante.util.permission.PermissionManager +import at.shockbytes.dante.util.scheduler.SchedulerFacade +import at.shockbytes.tracking.Tracker +import com.google.gson.Gson +import dagger.Module +import dagger.Provides + +@Module +class BookStorageModule(private val app: Application) { + + @Provides + fun provideInactiveShockbytesBackupStorage( + preferences: SharedPreferences + ): InactiveShockbytesBackupStorage { + return SharedPreferencesInactiveShockbytesBackupStorage(preferences) + } + + @Provides + fun provideBackupRepository( + backupProvider: Array, + preferences: SharedPreferences, + tracker: Tracker + ): BackupRepository { + return DefaultBackupRepository(backupProvider.toList(), preferences, tracker) + } + + @Provides + fun provideExternalStorageInteractor(): ExternalStorageInteractor { + return DefaultExternalStorageInteractor(app.applicationContext) + } + + @Provides + fun provideDriveClient(signInManager: SignInManager): DriveClient { + return DriveRestClient(signInManager as GoogleFirebaseSignInManager) + } + + @Provides + fun provideBackupProvider( + schedulerFacade: SchedulerFacade, + signInManager: SignInManager, + shockbytesHerokuApi: ShockbytesHerokuApi, + inactiveShockbytesBackupStorage: InactiveShockbytesBackupStorage, + externalStorageInteractor: ExternalStorageInteractor, + permissionManager: PermissionManager, + csvImportProvider: DanteCsvImportProvider, + driveClient: DriveClient + ): Array { + return arrayOf( + GoogleDriveBackupProvider( + schedulerFacade, + driveClient + ), + ShockbytesHerokuServerBackupProvider( + signInManager, + shockbytesHerokuApi, + inactiveShockbytesBackupStorage + ), + ExternalStorageBackupProvider( + schedulerFacade, + Gson(), + externalStorageInteractor, + permissionManager + ), + LocalCsvBackupProvider( + schedulerFacade, + externalStorageInteractor, + permissionManager, + csvImportProvider + ) + ) + } + + @Provides + fun provideDanteCsvImportProvider(schedulers: SchedulerFacade): DanteCsvImportProvider { + return DanteCsvImportProvider(CsvReader(), schedulers) + } + + @Provides + fun provideDanteExternalStorageImportProvider(): DanteExternalStorageImportProvider { + return DanteExternalStorageImportProvider(gson = Gson()) + } + + @Provides + fun provideImportProvider( + schedulers: SchedulerFacade, + danteCsvImportProvider: DanteCsvImportProvider, + danteExternalStorageImportProvider: DanteExternalStorageImportProvider + ): Array { + return arrayOf( + GoodreadsCsvImportProvider(CsvReader(), schedulers), + danteCsvImportProvider, + danteExternalStorageImportProvider + ) + } + + @Provides + fun provideImportRepository( + importProvider: Array, + bookRepository: BookRepository, + schedulers: SchedulerFacade + ): ImportRepository { + return DefaultImportRepository(importProvider, bookRepository, schedulers) + } +} \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/ui/activity/BookStorageActivity.kt b/app/src/main/java/at/shockbytes/dante/ui/activity/BookStorageActivity.kt index 81889480..5ec35e04 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/activity/BookStorageActivity.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/activity/BookStorageActivity.kt @@ -3,28 +3,23 @@ package at.shockbytes.dante.ui.activity import android.content.Context import android.content.Intent import android.os.Bundle -import android.view.Menu -import android.view.MenuItem import androidx.fragment.app.Fragment import at.shockbytes.dante.injection.AppComponent import androidx.lifecycle.ViewModelProvider import at.shockbytes.dante.R -import at.shockbytes.dante.ui.activity.core.BackNavigableActivity +import at.shockbytes.dante.ui.activity.core.BaseActivity import at.shockbytes.dante.ui.fragment.BackupFragment import at.shockbytes.dante.ui.fragment.ImportBooksStorageFragment import at.shockbytes.dante.ui.fragment.OnlineStorageFragment import javax.inject.Inject import at.shockbytes.dante.ui.viewmodel.BackupViewModel -import at.shockbytes.dante.util.addTo +import at.shockbytes.dante.util.setVisible import at.shockbytes.dante.util.viewModelOf -import at.shockbytes.util.AppUtils -import com.afollestad.materialdialogs.MaterialDialog -import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.android.synthetic.main.activity_book_storage.* +import kotlinx.android.synthetic.main.dante_toolbar.* import pub.devrel.easypermissions.EasyPermissions -import timber.log.Timber -class BookStorageActivity : BackNavigableActivity(), EasyPermissions.PermissionCallbacks { +class BookStorageActivity : BaseActivity(), EasyPermissions.PermissionCallbacks { @Inject lateinit var vmFactory: ViewModelProvider.Factory @@ -37,49 +32,22 @@ class BookStorageActivity : BackNavigableActivity(), EasyPermissions.PermissionC viewModel = viewModelOf(vmFactory) initializeNavigation() + hideActionBar() + setupToolbar() } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_book_storage, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - - if (item.itemId == R.id.menu_book_storage_delete_library) { - confirmLibraryDeletion() - return true - } - - return super.onOptionsItemSelected(item) - } - - private fun confirmLibraryDeletion() { - MaterialDialog(this).show { - icon(R.drawable.ic_burn) - title(text = getString(R.string.ask_for_library_deletion)) - message(text = getString(R.string.ask_for_library_deletion_msg)) - positiveButton(R.string.action_delete) { - deleteLibrary() - } - negativeButton(android.R.string.no) { - dismiss() + private fun setupToolbar() { + dante_toolbar_title.setText(R.string.label_book_storage) + dante_toolbar_back.apply { + setVisible(true) + setOnClickListener { + onBackPressed() } - cancelOnTouchOutside(false) - cornerRadius(AppUtils.convertDpInPixel(6, this@BookStorageActivity).toFloat()) } } - private fun deleteLibrary() { - viewModel.deleteLibrary() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ - showSnackbar(getString(R.string.library_deletion_succeeded)) - }, { throwable -> - Timber.e(throwable) - showSnackbar(getString(R.string.library_deletion_failed)) - }) - .addTo(compositeDisposable) + private fun hideActionBar() { + supportActionBar?.hide() } private fun initializeNavigation() { diff --git a/app/src/main/java/at/shockbytes/dante/ui/activity/MainActivity.kt b/app/src/main/java/at/shockbytes/dante/ui/activity/MainActivity.kt index 78970af4..205c9c83 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/activity/MainActivity.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/activity/MainActivity.kt @@ -1,6 +1,5 @@ package at.shockbytes.dante.ui.activity -import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import android.content.Context import android.content.Intent @@ -34,6 +33,7 @@ import at.shockbytes.dante.ui.fragment.AnnouncementFragment import at.shockbytes.dante.util.DanteUtils import at.shockbytes.dante.util.ExceptionHandlers import at.shockbytes.dante.util.addTo +import at.shockbytes.dante.util.isFragmentShown import at.shockbytes.dante.util.retrieveActiveActivityAlias import at.shockbytes.dante.util.runDelayed import at.shockbytes.dante.util.settings.LauncherIconState @@ -76,7 +76,6 @@ class MainActivity : BaseActivity(), ViewPager.OnPageChangeListener { setupUI() initializeNavigation() setupDarkMode() - checkForOnboardingHints() saveLauncherIconState() // goingEdgeToEdge() setupFabMorph() @@ -192,13 +191,15 @@ class MainActivity : BaseActivity(), ViewPager.OnPageChangeListener { }) .addTo(compositeDisposable) - viewModel.getUserEvent().observe(this, Observer { event -> + viewModel.getUserEvent().observe(this, { event -> when (event) { is MainViewModel.UserEvent.SuccessEvent -> { // Only show announcements once the user is logged in viewModel.queryAnnouncements() + // Only show onboarding hints after the user login state is resolved + checkForOnboardingHints() if (event.user != null) { val photoUrl = event.user.photoUrl @@ -246,11 +247,16 @@ class MainActivity : BaseActivity(), ViewPager.OnPageChangeListener { } private fun showAnnouncementFragment() { - supportFragmentManager.beginTransaction() - .setCustomAnimations(0, R.anim.fade_out, 0, R.anim.fade_out) - .add(android.R.id.content, AnnouncementFragment.newInstance()) - .addToBackStack(null) - .commit() + + with(supportFragmentManager) { + if (!isFragmentShown(TAG_ANNOUNCEMENT)) { + beginTransaction() + .setCustomAnimations(0, R.anim.fade_out, 0, R.anim.fade_out) + .add(android.R.id.content, AnnouncementFragment.newInstance(), TAG_ANNOUNCEMENT) + .addToBackStack(null) + .commit() + } + } } private fun handleIntentExtras() { @@ -287,7 +293,7 @@ class MainActivity : BaseActivity(), ViewPager.OnPageChangeListener { // It has to be delayed, otherwise it will appear on the wrong // position on top of the BottomNavigationBar - runDelayed(1500) { + runDelayed(1000) { if (danteSettings.isFirstAppOpen) { danteSettings.isFirstAppOpen = false showOnboardingHintViews() @@ -302,7 +308,6 @@ class MainActivity : BaseActivity(), ViewPager.OnPageChangeListener { } private fun showOnboardingHintViews() { - MaterialTapTargetPrompt.Builder(this) .setTarget(R.id.mainFab) .setFocalColour(ContextCompat.getColor(this, android.R.color.transparent)) @@ -353,7 +358,7 @@ class MainActivity : BaseActivity(), ViewPager.OnPageChangeListener { GoogleWelcomeScreenDialogFragment .newInstance(account.givenName, account.photoUrl) .setOnAcknowledgedListener { - viewModel.showSignInWelcomeScreen(false) + viewModel.disableShowWelcomeScreen() } .show(supportFragmentManager, GOOGLE_SIGNIN_FRAGMENT) } @@ -429,6 +434,8 @@ class MainActivity : BaseActivity(), ViewPager.OnPageChangeListener { companion object { + private const val TAG_ANNOUNCEMENT = "announcement-tag" + private const val GOOGLE_SIGNIN_FRAGMENT = "google_welcome_dialog_fragment" private const val ARG_OPEN_CAMERA_AFTER_LAUNCH = "arg_open_camera_after_lunch" diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/BackupEntryAdapter.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/BackupEntryAdapter.kt index 680cab22..99019a19 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/adapter/BackupEntryAdapter.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/BackupEntryAdapter.kt @@ -8,11 +8,11 @@ import androidx.appcompat.view.menu.MenuPopupHelper import androidx.appcompat.widget.PopupMenu import androidx.core.content.ContextCompat import at.shockbytes.dante.R +import at.shockbytes.dante.backup.model.BackupMetadata import at.shockbytes.dante.backup.model.BackupMetadataState import at.shockbytes.dante.util.DanteUtils import at.shockbytes.dante.util.setVisible import at.shockbytes.util.adapter.BaseAdapter -import at.shockbytes.util.adapter.ItemTouchHelperAdapter import kotlinx.android.extensions.LayoutContainer import kotlinx.android.synthetic.main.item_backup_entry.* @@ -24,23 +24,16 @@ class BackupEntryAdapter( ctx: Context, onItemClickListener: OnItemClickListener, private val onItemOverflowMenuClickedListener: OnBackupOverflowItemListener -) : BaseAdapter(ctx, onItemClickListener), ItemTouchHelperAdapter { +) : BaseAdapter(ctx, onItemClickListener) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { return BackupViewHolder(inflater.inflate(R.layout.item_backup_entry, parent, false)) } - override fun onItemMove(from: Int, to: Int) = false - - override fun onItemMoveFinished() = Unit - - override fun onItemDismiss(position: Int) { - onItemMoveListener?.onItemDismissed(data[position], position) - } - - fun updateData(backupStates: List) { + fun updateData(freshData: List) { data.clear() - data.addAll(backupStates) + data.addAll(freshData) + notifyDataSetChanged() } inner class BackupViewHolder( @@ -71,21 +64,32 @@ class BackupEntryAdapter( private fun setupOverflowMenu(content: BackupMetadataState) { + val entry = content.entry val popupMenu = PopupMenu(context, item_backup_entry_btn_overflow) popupMenu.menuInflater.inflate(R.menu.menu_backup_item_overflow, popupMenu.menu) popupMenu.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { R.id.menu_backup_delete -> { - onItemOverflowMenuClickedListener.onBackupItemDeleted(content.entry, getLocation(content)) + onItemOverflowMenuClickedListener.onBackupItemDeleted(entry, getLocation(content)) + } + R.id.menu_backup_export_request -> { + if (entry is BackupMetadata.WithLocalFile) { + onItemOverflowMenuClickedListener.onBackupItemDownloadRequest(entry) + } } - R.id.menu_backup_download_request -> { - onItemOverflowMenuClickedListener.onBackupItemDownloadRequest(content.entry) + R.id.menu_backup_open_request -> { + if (entry is BackupMetadata.WithLocalFile) { + onItemOverflowMenuClickedListener.onBackupItemOpenFileRequest(entry) + } } } true } - popupMenu.menu.findItem(R.id.menu_backup_download_request)?.isVisible = content.isFileDownloadable + + val showExportOption = content.isFileExportable && entry is BackupMetadata.WithLocalFile + popupMenu.menu.findItem(R.id.menu_backup_open_request)?.isVisible = showExportOption + popupMenu.menu.findItem(R.id.menu_backup_export_request)?.isVisible = showExportOption val menuHelper = MenuPopupHelper(context, popupMenu.menu as MenuBuilder, item_backup_entry_btn_overflow) menuHelper.setForceShowIcon(true) diff --git a/app/src/main/java/at/shockbytes/dante/ui/adapter/OnBackupOverflowItemListener.kt b/app/src/main/java/at/shockbytes/dante/ui/adapter/OnBackupOverflowItemListener.kt index fe196ffc..18c4d73d 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/adapter/OnBackupOverflowItemListener.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/adapter/OnBackupOverflowItemListener.kt @@ -6,5 +6,7 @@ interface OnBackupOverflowItemListener { fun onBackupItemDeleted(content: BackupMetadata, location: Int) - fun onBackupItemDownloadRequest(content: BackupMetadata) + fun onBackupItemDownloadRequest(content: BackupMetadata.WithLocalFile) + + fun onBackupItemOpenFileRequest(content: BackupMetadata.WithLocalFile) } diff --git a/app/src/main/java/at/shockbytes/dante/ui/fragment/AnnouncementFragment.kt b/app/src/main/java/at/shockbytes/dante/ui/fragment/AnnouncementFragment.kt index b92e9697..31241c9a 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/fragment/AnnouncementFragment.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/fragment/AnnouncementFragment.kt @@ -1,11 +1,12 @@ package at.shockbytes.dante.ui.fragment import android.os.Bundle -import androidx.lifecycle.Observer +import android.view.View import androidx.lifecycle.ViewModelProvider import at.shockbytes.dante.R import at.shockbytes.dante.announcement.Announcement import at.shockbytes.dante.injection.AppComponent +import at.shockbytes.dante.navigation.ActivityNavigator import at.shockbytes.dante.ui.viewmodel.AnnouncementViewModel import at.shockbytes.dante.util.MailLauncher import at.shockbytes.dante.util.UrlLauncher @@ -13,6 +14,7 @@ import at.shockbytes.dante.util.setVisible import at.shockbytes.dante.util.viewModelOfActivity import com.airbnb.lottie.LottieDrawable import kotlinx.android.synthetic.main.fragment_announcement.* +import kotlinx.android.synthetic.main.fragment_book_action_sheet.view.* import javax.inject.Inject class AnnouncementFragment : BaseFragment() { @@ -38,7 +40,7 @@ class AnnouncementFragment : BaseFragment() { override fun bindViewModel() { viewModel.requestAnnouncementState() - viewModel.getAnnouncementState().observe(this, Observer { announcementState -> + viewModel.getAnnouncementState().observe(this, { announcementState -> when (announcementState) { is AnnouncementViewModel.AnnouncementState.Active -> { populateAnnouncementViews(announcementState.announcement) @@ -57,16 +59,31 @@ class AnnouncementFragment : BaseFragment() { when (illustration) { is Announcement.Illustration.LottieIllustration -> { + iv_announcement.setVisible(false) lottie_announcement.apply { setVisible(true) repeatCount = LottieDrawable.INFINITE setAnimation(illustration.lottieRes) } } + is Announcement.Illustration.ImageIllustration -> { + lottie_announcement.setVisible(false, invisibilityState = View.INVISIBLE) + iv_announcement.apply { + setVisible(true) + setImageResource(illustration.drawableRes) + } + } } - card_announcement.setOnClickListener { - performAnnouncementAction(this.action) + btn_announcement_action.apply { + setVisible(hasAction) + + this@with.action?.let { action -> + setOnClickListener { + performAnnouncementAction(action) + } + setText(action.actionLabel) + } } layout_announcement.setOnClickListener { @@ -78,7 +95,7 @@ class AnnouncementFragment : BaseFragment() { } } - private fun performAnnouncementAction(action: Announcement.Action?) { + private fun performAnnouncementAction(action: Announcement.Action) { when (action) { is Announcement.Action.OpenUrl -> { UrlLauncher.launchUrl(requireContext(), action.url) @@ -86,6 +103,9 @@ class AnnouncementFragment : BaseFragment() { is Announcement.Action.Mail -> { MailLauncher.sendMail(requireActivity(), getString(action.subject), getString(R.string.mail_body_translation)) } + is Announcement.Action.OpenScreen -> { + ActivityNavigator.navigateTo(context, action.destination) + } } } diff --git a/app/src/main/java/at/shockbytes/dante/ui/fragment/BackupBackupFragment.kt b/app/src/main/java/at/shockbytes/dante/ui/fragment/BackupBackupFragment.kt index 52dba502..317a7767 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/fragment/BackupBackupFragment.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/fragment/BackupBackupFragment.kt @@ -45,29 +45,34 @@ class BackupBackupFragment : BaseFragment() { override fun bindViewModel() { - viewModel.getLastBackupTime().observe(this, Observer { lastBackup -> - tv_fragment_backup_last_backup.text = getString(R.string.last_backup, lastBackup) - }) + viewModel.getLastBackupTime() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { lastBackup -> + tv_fragment_backup_last_backup.text = getString(R.string.last_backup, lastBackup) + } + .addTo(compositeDisposable) viewModel.getActiveBackupProviders().observe(this, Observer(::setupBackupProviderUI)) viewModel.makeBackupEvent .observeOn(AndroidSchedulers.mainThread()) - .subscribe { state -> - when (state) { - is BackupViewModel.State.Success -> { - showSnackbar(getString(R.string.backup_created), showLong = false) + .subscribe(::handleBackupState) + .addTo(compositeDisposable) + } - if (state.switchToBackupTab) { - switchToBackupTab() - } - } - is BackupViewModel.State.Error -> { - showSnackbar(getString(R.string.backup_not_created)) - } + private fun handleBackupState(state: BackupViewModel.State) { + when (state) { + is BackupViewModel.State.Success -> { + showSnackbar(getString(R.string.backup_created), showLong = false) + + if (state.switchToBackupTab) { + switchToBackupTab() } } - .addTo(compositeDisposable) + is BackupViewModel.State.Error -> { + showSnackbar(getString(R.string.backup_not_created)) + } + } } private fun switchToBackupTab() { diff --git a/app/src/main/java/at/shockbytes/dante/ui/fragment/BackupRestoreFragment.kt b/app/src/main/java/at/shockbytes/dante/ui/fragment/BackupRestoreFragment.kt index 44b11bcb..8171d68a 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/fragment/BackupRestoreFragment.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/fragment/BackupRestoreFragment.kt @@ -1,8 +1,8 @@ package at.shockbytes.dante.ui.fragment +import android.content.Intent import android.os.Bundle import android.view.View -import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager @@ -18,6 +18,7 @@ import at.shockbytes.dante.ui.fragment.dialog.RestoreStrategyDialogFragment import at.shockbytes.dante.ui.viewmodel.BackupViewModel import at.shockbytes.dante.util.addTo import at.shockbytes.dante.util.isPortrait +import at.shockbytes.dante.util.openFile import at.shockbytes.dante.util.setVisible import at.shockbytes.dante.util.viewModelOfActivity import at.shockbytes.util.adapter.BaseAdapter @@ -49,8 +50,13 @@ class BackupRestoreFragment : BaseFragment(), BaseAdapter.OnItemClickListener + viewModel.getBackupState().observe(this, { state -> when (state) { is BackupViewModel.LoadBackupState.Success -> { @@ -160,14 +166,28 @@ class BackupRestoreFragment : BaseFragment(), BaseAdapter.OnItemClickListener + viewModel.getUserEvent().observe(this, { event -> when (event) { @@ -86,6 +86,9 @@ class MenuFragment : BottomSheetDialogFragment() { .setMaybeLaterListener { viewModel.signInMaybeLater(true) } .show(childFragmentManager, "sign-in-fragment") } + is MainViewModel.UserEvent.ErrorEvent -> { + Toast.makeText(context, event.errorMsg, Toast.LENGTH_LONG).show() + } } }) } diff --git a/app/src/main/java/at/shockbytes/dante/ui/fragment/dialog/GoogleWelcomeScreenDialogFragment.kt b/app/src/main/java/at/shockbytes/dante/ui/fragment/dialog/GoogleWelcomeScreenDialogFragment.kt index 909442f7..149ca543 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/fragment/dialog/GoogleWelcomeScreenDialogFragment.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/fragment/dialog/GoogleWelcomeScreenDialogFragment.kt @@ -43,7 +43,7 @@ class GoogleWelcomeScreenDialogFragment : BaseDialogFragment() { } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return AlertDialog.Builder(context!!) + return AlertDialog.Builder(requireContext()) .setView(welcomeView) .setPositiveButton(getString(R.string.welcome_acknowledge)) { _, _ -> listener?.invoke() diff --git a/app/src/main/java/at/shockbytes/dante/ui/fragment/dialog/RestoreStrategyDialogFragment.kt b/app/src/main/java/at/shockbytes/dante/ui/fragment/dialog/RestoreStrategyDialogFragment.kt index e6b1c2e3..5650f9ee 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/fragment/dialog/RestoreStrategyDialogFragment.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/fragment/dialog/RestoreStrategyDialogFragment.kt @@ -27,7 +27,7 @@ class RestoreStrategyDialogFragment : DialogFragment() { } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return AlertDialog.Builder(context!!) + return AlertDialog.Builder(requireContext()) .setView(strategyView) .create() .also { it.requestWindowFeature(Window.FEATURE_NO_TITLE) } diff --git a/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BackupViewModel.kt b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BackupViewModel.kt index db8ea0da..84f8147b 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BackupViewModel.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BackupViewModel.kt @@ -5,16 +5,18 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import at.shockbytes.dante.backup.model.BackupMetadata import at.shockbytes.dante.backup.BackupRepository +import at.shockbytes.dante.backup.model.BackupContent import at.shockbytes.dante.backup.model.BackupMetadataState import at.shockbytes.dante.backup.model.BackupStorageProvider import at.shockbytes.dante.util.RestoreStrategy import at.shockbytes.dante.core.data.BookRepository -import at.shockbytes.dante.util.DanteUtils +import at.shockbytes.dante.core.data.PageRecordDao +import at.shockbytes.dante.util.DanteUtils.formatTimestamp import at.shockbytes.dante.util.addTo import at.shockbytes.dante.util.scheduler.SchedulerFacade import at.shockbytes.tracking.Tracker import at.shockbytes.tracking.event.DanteTrackingEvent -import io.reactivex.Completable +import io.reactivex.Observable import io.reactivex.subjects.PublishSubject import timber.log.Timber import javax.inject.Inject @@ -25,6 +27,7 @@ import javax.inject.Inject */ class BackupViewModel @Inject constructor( private val bookRepository: BookRepository, + private val pageRecordDao: PageRecordDao, private val backupRepository: BackupRepository, private val schedulers: SchedulerFacade, private val tracker: Tracker @@ -33,8 +36,12 @@ class BackupViewModel @Inject constructor( private val loadBackupState = MutableLiveData() fun getBackupState(): LiveData = loadBackupState - private val lastBackupTime = MutableLiveData() - fun getLastBackupTime(): LiveData = lastBackupTime + fun getLastBackupTime(): Observable { + return backupRepository.observeLastBackupTime() + .map { lastBackupMillis -> + if (lastBackupMillis > 0) formatTimestamp(lastBackupMillis) else "---" + } + } private val activeBackupStorageProviders = MutableLiveData>() fun getActiveBackupProviders(): LiveData> = activeBackupStorageProviders @@ -49,7 +56,6 @@ class BackupViewModel @Inject constructor( .doOnComplete(::postActiveBackupProviders) .subscribe({ loadBackupState() - updateLastBackupTime() }, { throwable -> Timber.e(throwable) errorSubject.onNext(throwable) @@ -63,11 +69,11 @@ class BackupViewModel @Inject constructor( } fun applyBackup(t: BackupMetadata, strategy: RestoreStrategy) { - backupRepository.restoreBackup(t, bookRepository, strategy) + backupRepository.restoreBackup(t, bookRepository, pageRecordDao, strategy) .subscribeOn(schedulers.io) .observeOn(schedulers.ui) .subscribe({ - val formattedTimestamp = DanteUtils.formatTimestamp(t.timestamp) + val formattedTimestamp = formatTimestamp(t.timestamp) applyBackupEvent.onNext(ApplyBackupState.Success(formattedTimestamp)) }) { throwable -> Timber.e(throwable) @@ -77,13 +83,24 @@ class BackupViewModel @Inject constructor( } fun makeBackup(backupStorageProvider: BackupStorageProvider) { - backupRepository.backup(bookRepository.bookObservable.blockingFirst(listOf()), backupStorageProvider) + + Observable + .combineLatest( + bookRepository.bookObservable, + pageRecordDao.allPageRecords(), + { books, records -> BackupContent(books, records) } + ) + .firstOrError() + .flatMapCompletable { backupContent -> + backupRepository.backup(backupContent, backupStorageProvider) + } .subscribeOn(schedulers.io) .observeOn(schedulers.ui) + .doOnComplete { + makeBackupEvent.onNext(State.Success(switchToBackupTab = true)) + } .subscribe({ - updateLastBackupTime() loadBackupState() - makeBackupEvent.onNext(State.Success(switchToBackupTab = true)) }) { throwable -> Timber.e(throwable) makeBackupEvent.onNext(State.Error(throwable)) @@ -100,7 +117,7 @@ class BackupViewModel @Inject constructor( deleteBackupEvent.onNext(DeleteBackupState.Success(position, wasLastEntry)) if (wasLastEntry) { - updateLastBackupTime(true) + resetLastBackupTime() } }) { throwable -> Timber.e(throwable) @@ -117,31 +134,24 @@ class BackupViewModel @Inject constructor( .subscribeOn(schedulers.io) .subscribe({ backupEntries -> - // Check if backups are empty. One could argue that we can evaluate this in the fragment, - // this solution seems cleaner, because it doesn't bother the view with even the simplest logic - if (backupEntries.isNotEmpty()) { - loadBackupState.postValue(LoadBackupState.Success(backupEntries)) - } else { - loadBackupState.postValue(LoadBackupState.Empty) - } - }) { throwable -> - Timber.e(throwable) - loadBackupState.postValue(LoadBackupState.Error(throwable)) - }.addTo(compositeDisposable) + // Check if backups are empty. One could argue that we can evaluate this in the fragment, + // this solution seems cleaner, because it doesn't bother the view with even the simplest logic + if (backupEntries.isNotEmpty()) { + loadBackupState.postValue(LoadBackupState.Success(backupEntries)) + } else { + loadBackupState.postValue(LoadBackupState.Empty) + } + }) { throwable -> + Timber.e(throwable) + loadBackupState.postValue(LoadBackupState.Error(throwable)) + }.addTo(compositeDisposable) } - private fun updateLastBackupTime(resetValue: Boolean = false) { - - // Reset the value if the last item was dismissed - if (resetValue) { - backupRepository.lastBackupTime = 0 - } - - val lastBackupMillis = backupRepository.lastBackupTime - val lastBackup = if (lastBackupMillis > 0) - DanteUtils.formatTimestamp(lastBackupMillis) - else "---" - lastBackupTime.postValue(lastBackup) + /** + * Reset the value if the last item was dismissed. + */ + private fun resetLastBackupTime() { + backupRepository.setLastBackupTime(0L) } private fun postActiveBackupProviders() { @@ -152,25 +162,8 @@ class BackupViewModel @Inject constructor( activeBackupStorageProviders.postValue(providers) } - fun deleteLibrary(): Completable { - return Completable - .create { emitter -> - - val books = bookRepository.bookObservable.blockingFirst() - - if (books.isEmpty()) { - emitter.onError(IllegalStateException("No library to burn down")) - } - - books - .map { it.id } - .forEach(bookRepository::delete) - - emitter.onComplete() - } - .doOnComplete { - tracker.track(DanteTrackingEvent.BurnDownLibrary) - } + fun trackOpenFileEvent(storageProvider: BackupStorageProvider) { + tracker.track(DanteTrackingEvent.OpenBackupFile(storageProvider.acronym)) } // -------------------------- State classes -------------------------- diff --git a/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BookDetailViewModel.kt b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BookDetailViewModel.kt index 483d89b1..7adb53f8 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BookDetailViewModel.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/BookDetailViewModel.kt @@ -233,7 +233,7 @@ class BookDetailViewModel @Inject constructor( private fun checkDateBoundaries(wishlist: Long, start: Long, end: Long): Boolean { - // Wish list specific cases + // Wish listBackupFiles specific cases if ((wishlist <= start || start == 0L) && (wishlist <= end || end == 0L)) { return true } diff --git a/app/src/main/java/at/shockbytes/dante/ui/viewmodel/MainViewModel.kt b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/MainViewModel.kt index c4909e99..65e7d157 100644 --- a/app/src/main/java/at/shockbytes/dante/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/at/shockbytes/dante/ui/viewmodel/MainViewModel.kt @@ -2,6 +2,7 @@ package at.shockbytes.dante.ui.viewmodel import androidx.lifecycle.MutableLiveData import android.content.Intent +import androidx.annotation.StringRes import androidx.lifecycle.LiveData import at.shockbytes.dante.R import at.shockbytes.dante.announcement.AnnouncementProvider @@ -11,6 +12,7 @@ import at.shockbytes.dante.signin.UserState import at.shockbytes.dante.util.ExceptionHandlers import at.shockbytes.dante.util.addTo import at.shockbytes.dante.util.scheduler.SchedulerFacade +import at.shockbytes.dante.util.settings.DanteSettings import io.reactivex.Completable import io.reactivex.Observable import io.reactivex.subjects.PublishSubject @@ -24,13 +26,17 @@ import javax.inject.Inject class MainViewModel @Inject constructor( private val signInManager: SignInManager, private val announcementProvider: AnnouncementProvider, - private val schedulers: SchedulerFacade + private val schedulers: SchedulerFacade, + private val danteSettings: DanteSettings ) : BaseViewModel() { sealed class UserEvent { + data class SuccessEvent(val user: DanteUser?, val showWelcomeScreen: Boolean) : UserEvent() - class LoginEvent(val signInIntent: Intent?) : UserEvent() - data class ErrorEvent(val errorMsg: Int) : UserEvent() + + data class LoginEvent(val signInIntent: Intent?) : UserEvent() + + data class ErrorEvent(@StringRes val errorMsg: Int) : UserEvent() } private val userEvent = MutableLiveData() @@ -98,12 +104,22 @@ class MainViewModel @Inject constructor( signInManager.maybeLater = maybeLater } - fun showSignInWelcomeScreen(showWelcomeScreen: Boolean) { - signInManager.showWelcomeScreen = showWelcomeScreen + fun disableShowWelcomeScreen() { + signInManager.showWelcomeScreen = false + hideWelcomeScreenFlagFromPostedLiveData() + } + + private fun hideWelcomeScreenFlagFromPostedLiveData() { + (userEvent.value as? UserEvent.SuccessEvent)?.let { event -> + userEvent.postValue(event.copy(showWelcomeScreen = false)) + } } fun queryAnnouncements() { val hasActiveAnnouncement = announcementProvider.getActiveAnnouncement() != null - activeAnnouncement.onNext(hasActiveAnnouncement) + // Do not show announcements if the user first logs into the app, even though there would + // be a new announcement + val showAnnouncement = hasActiveAnnouncement && !danteSettings.isFirstAppOpen + activeAnnouncement.onNext(showAnnouncement) } } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/util/AppModuleExtensions.kt b/app/src/main/java/at/shockbytes/dante/util/AppModuleExtensions.kt index c3e82368..50762b1c 100644 --- a/app/src/main/java/at/shockbytes/dante/util/AppModuleExtensions.kt +++ b/app/src/main/java/at/shockbytes/dante/util/AppModuleExtensions.kt @@ -1,19 +1,24 @@ package at.shockbytes.dante.util +import android.content.Context +import android.content.Intent +import android.net.Uri import android.os.Handler +import android.os.Looper +import at.shockbytes.dante.core.R import at.shockbytes.dante.signin.DanteUser import com.google.android.gms.auth.api.signin.GoogleSignInAccount import com.google.android.gms.tasks.Tasks import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.firebase.auth.FirebaseUser +import java.io.File fun FloatingActionButton.toggle(millis: Long = 300) { - if (isExpanded) { isExpanded = false } else { hide() - Handler().postDelayed({ show() }, millis) + Handler(Looper.getMainLooper()).postDelayed({ show() }, millis) } } @@ -39,4 +44,22 @@ fun FirebaseUser.toDanteUser(givenName: String? = this.displayName): DanteUser { Tasks.await(this.getIdToken(false))?.token, this.uid ) +} + +fun Context.shareFile(fileToPath: File): Intent { + return Intent() + .setAction(Intent.ACTION_SEND) + .putExtra(Intent.EXTRA_TEXT, getString(R.string.share_file_template, fileToPath.name)) + .putExtra(Intent.EXTRA_STREAM, Uri.fromFile(fileToPath)) + .setType("text/plain") +} + +fun Context.openFile(fileToPath: File, mimeType: String): Intent { + + val uri = Uri.fromFile(fileToPath) + + return Intent() + .setAction(Intent.ACTION_VIEW) + .setDataAndType(uri, mimeType) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } \ No newline at end of file diff --git a/app/src/main/java/at/shockbytes/dante/util/settings/delegate/DelegateExtensions.kt b/app/src/main/java/at/shockbytes/dante/util/settings/delegate/SharedPreferencesExtensions.kt similarity index 70% rename from app/src/main/java/at/shockbytes/dante/util/settings/delegate/DelegateExtensions.kt rename to app/src/main/java/at/shockbytes/dante/util/settings/delegate/SharedPreferencesExtensions.kt index 4941cb06..2065f9a0 100644 --- a/app/src/main/java/at/shockbytes/dante/util/settings/delegate/DelegateExtensions.kt +++ b/app/src/main/java/at/shockbytes/dante/util/settings/delegate/SharedPreferencesExtensions.kt @@ -10,4 +10,12 @@ fun SharedPreferences.boolDelegate( fun SharedPreferences.stringDelegate( key: String, defaultValue: String -): SharedPreferencesStringPropertyDelegate = SharedPreferencesStringPropertyDelegate(this, key, defaultValue) \ No newline at end of file +): SharedPreferencesStringPropertyDelegate = SharedPreferencesStringPropertyDelegate(this, key, defaultValue) + +fun SharedPreferences.edit( + block: SharedPreferences.Editor.() -> Unit +) { + this.edit() + .apply(block) + .apply() +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_export.xml b/app/src/main/res/drawable/ic_export.xml new file mode 100644 index 00000000..d242dbc5 --- /dev/null +++ b/app/src/main/res/drawable/ic_export.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_google_drive.xml b/app/src/main/res/drawable/ic_google_drive.xml index 6d73c71b..ff1f172b 100644 --- a/app/src/main/res/drawable/ic_google_drive.xml +++ b/app/src/main/res/drawable/ic_google_drive.xml @@ -1,6 +1,24 @@ - - - - + + + + + + + diff --git a/app/src/main/res/drawable/ic_open.xml b/app/src/main/res/drawable/ic_open.xml new file mode 100644 index 00000000..442c84d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_open.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_restore_merge.xml b/app/src/main/res/drawable/ic_restore_merge.xml index b149e44d..00e9db2d 100644 --- a/app/src/main/res/drawable/ic_restore_merge.xml +++ b/app/src/main/res/drawable/ic_restore_merge.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_restore_overwrite.xml b/app/src/main/res/drawable/ic_restore_overwrite.xml index a037019a..dc5d1db0 100644 --- a/app/src/main/res/drawable/ic_restore_overwrite.xml +++ b/app/src/main/res/drawable/ic_restore_overwrite.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/layout/activity_book_storage.xml b/app/src/main/res/layout/activity_book_storage.xml index e3c00c21..973708e7 100644 --- a/app/src/main/res/layout/activity_book_storage.xml +++ b/app/src/main/res/layout/activity_book_storage.xml @@ -4,14 +4,22 @@ android:layout_height="match_parent" xmlns:tools="http://schemas.android.com/tools"> + + + app:layout_constraintTop_toBottomOf="@+id/toolbar_book_storage" /> @@ -35,33 +34,39 @@ - + android:textColor="@color/nice_color" + app:icon="@drawable/ic_restore_merge" + app:iconGravity="top" + app:iconTint="@color/nice_color" + app:rippleColor="@color/nice_color" /> - + android:textColor="@color/iconColor" + app:icon="@drawable/ic_restore_overwrite" + app:iconGravity="top" + app:iconTint="@color/iconColor" + app:rippleColor="@color/iconColor" /> diff --git a/app/src/main/res/layout/fragment_announcement.xml b/app/src/main/res/layout/fragment_announcement.xml index ed878540..90f3b2ad 100644 --- a/app/src/main/res/layout/fragment_announcement.xml +++ b/app/src/main/res/layout/fragment_announcement.xml @@ -63,7 +63,7 @@ + + + + + + diff --git a/app/src/main/res/layout/fragment_backup_backup.xml b/app/src/main/res/layout/fragment_backup_backup.xml index f769f754..2d2b8a59 100644 --- a/app/src/main/res/layout/fragment_backup_backup.xml +++ b/app/src/main/res/layout/fragment_backup_backup.xml @@ -29,6 +29,7 @@ android:layout_marginStart="16dp" android:layout_marginTop="24dp" android:layout_marginEnd="16dp" + android:gravity="center" android:text="@string/backup_provider_rationale" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/item_backup_entry.xml b/app/src/main/res/layout/item_backup_entry.xml index 4261ae47..0f6e0ac3 100644 --- a/app/src/main/res/layout/item_backup_entry.xml +++ b/app/src/main/res/layout/item_backup_entry.xml @@ -67,11 +67,10 @@ android:layout_height="wrap_content" android:layout_below="@+id/item_backup_entry_txt_books" android:layout_marginTop="8dp" - android:layout_marginBottom="16dp" android:layout_toEndOf="@+id/item_backup_entry_imgview_provider" android:gravity="center" android:textAppearance="@style/TextAppearance.AppCompat.Caption" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toBottomOf="@+id/item_backup_entry_txt_books" app:layout_constraintEnd_toEndOf="@id/item_backup_entry_iv_device" app:layout_constraintStart_toStartOf="@id/item_backup_entry_iv_device" app:layout_constraintTop_toBottomOf="@id/item_backup_entry_iv_device" @@ -117,11 +116,12 @@ android:layout_marginLeft="8dp" android:layout_marginTop="24dp" android:layout_marginEnd="8dp" + app:tint="@color/menuItemColor" android:layout_marginRight="8dp" app:layout_constraintEnd_toStartOf="@+id/guideline3" app:layout_constraintStart_toEndOf="@+id/item_backup_entry_imgview_provider" app:layout_constraintTop_toBottomOf="@+id/item_backup_entry_txt_time" - app:srcCompat="@drawable/ic_book_count" /> + app:srcCompat="@drawable/ic_tab_current" /> \ No newline at end of file diff --git a/app/src/main/res/menu/menu_backup_item_overflow.xml b/app/src/main/res/menu/menu_backup_item_overflow.xml index 25646ee4..81437f3b 100644 --- a/app/src/main/res/menu/menu_backup_item_overflow.xml +++ b/app/src/main/res/menu/menu_backup_item_overflow.xml @@ -2,13 +2,20 @@ + + + + - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_book_storage.xml b/app/src/main/res/menu/menu_book_storage.xml deleted file mode 100644 index c48a6660..00000000 --- a/app/src/main/res/menu/menu_book_storage.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index d722ed79..83319de2 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -9,6 +9,8 @@ @transition/hero_transition @transition/hero_transition + @style/AppTheme.Appearance.ActionBarStyle + @font/montserrat @font/montserrat @font/montserrat @@ -20,6 +22,10 @@ 18sp +