From 2ce625ac87302d8ea9132da7573a252ec3f6b532 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 9 Jul 2019 19:22:24 +0200 Subject: [PATCH 01/10] Huge refactoring of backup transport * to get rid of global state * to have a testable architecture * to allow for authenticated encryption * to have a backup format version * to potentially allow for other storage plugins --- .travis.yml | 5 +- app/build.gradle | 34 +- app/libs/commons-io-2.6.jar | Bin 214788 -> 0 bytes .../java/com/stevesoltys/backup/Backup.kt | 5 + .../backup/NotificationBackupObserver.kt | 6 +- .../RestoreBackupActivityController.java | 5 +- .../backup/crypto/CipherFactory.kt | 31 ++ .../com/stevesoltys/backup/crypto/Crypto.kt | 138 +++++++ .../backup/{security => crypto}/KeyManager.kt | 40 +- .../com/stevesoltys/backup/header/Header.kt | 43 ++ .../stevesoltys/backup/header/HeaderReader.kt | 72 ++++ .../stevesoltys/backup/header/HeaderWriter.kt | 50 +++ .../backup/security/CipherUtil.java | 79 ---- .../backup/security/KeyGenerator.java | 44 --- .../backup/service/PackageService.java | 48 --- .../backup/service/PackageService.kt | 57 +++ .../backup/service/backup/BackupObserver.java | 5 - .../service/restore/RestoreService.java | 5 - .../backup/settings/BackupLocationFragment.kt | 2 + .../backup/settings/RecoveryCodeViewModel.kt | 4 +- .../backup/settings/SettingsFragment.kt | 3 +- .../backup/settings/SettingsViewModel.kt | 13 +- .../ConfigurableBackupTransport.java | 199 ---------- .../transport/ConfigurableBackupTransport.kt | 160 ++++++++ .../ConfigurableBackupTransportService.java | 43 -- .../ConfigurableBackupTransportService.kt | 37 ++ .../backup/transport/PluginManager.kt | 56 +++ .../transport/backup/BackupCoordinator.kt | 144 +++++++ .../backup/transport/backup/BackupPlugin.kt | 27 ++ .../backup/transport/backup/FullBackup.kt | 191 +++++++++ .../transport/backup/FullBackupPlugin.kt | 17 + .../backup/transport/backup/InputFactory.kt | 21 + .../backup/transport/backup/KVBackup.kt | 200 ++++++++++ .../backup/transport/backup/KVBackupPlugin.kt | 48 +++ .../plugins/DocumentsProviderBackupPlugin.kt | 46 +++ .../plugins/DocumentsProviderFullBackup.kt | 33 ++ .../plugins/DocumentsProviderKVBackup.kt | 54 +++ .../backup/plugins/DocumentsStorage.kt | 123 ++++++ .../transport/component/BackupComponent.java | 38 -- .../transport/component/RestoreComponent.java | 31 -- .../ContentProviderBackupComponent.java | 367 ------------------ .../ContentProviderBackupConstants.java | 16 - .../provider/ContentProviderBackupState.java | 109 ------ .../ContentProviderRestoreComponent.java | 360 ----------------- .../provider/ContentProviderRestoreState.java | 106 ----- .../backup/transport/restore/FullRestore.kt | 172 ++++++++ .../transport/restore/FullRestorePlugin.kt | 18 + .../backup/transport/restore/KVRestore.kt | 140 +++++++ .../transport/restore/KVRestorePlugin.kt | 30 ++ .../backup/transport/restore/OutputFactory.kt | 21 + .../transport/restore/RestoreCoordinator.kt | 155 ++++++++ .../backup/transport/restore/RestorePlugin.kt | 28 ++ .../DocumentsProviderFullRestorePlugin.kt | 25 ++ .../DocumentsProviderKVRestorePlugin.kt | 42 ++ .../plugins/DocumentsProviderRestorePlugin.kt | 29 ++ app/src/main/res/values/strings.xml | 1 + .../java/com/stevesoltys/backup/TestUtils.kt | 38 ++ .../backup/crypto/CryptoImplTest.kt | 53 +++ .../backup/crypto/CryptoIntegrationTest.kt | 44 +++ .../stevesoltys/backup/crypto/CryptoTest.kt | 192 +++++++++ .../backup/crypto/KeyManagerTestImpl.kt | 26 ++ .../backup/header/HeaderReaderTest.kt | 274 +++++++++++++ .../backup/header/HeaderWriterReaderTest.kt | 102 +++++ .../transport/CoordinatorIntegrationTest.kt | 162 ++++++++ .../backup/transport/TransportTest.kt | 30 ++ .../transport/backup/BackupCoordinatorTest.kt | 110 ++++++ .../backup/transport/backup/BackupTest.kt | 20 + .../backup/transport/backup/FullBackupTest.kt | 276 +++++++++++++ .../backup/transport/backup/KVBackupTest.kt | 216 +++++++++++ .../transport/restore/FullRestoreTest.kt | 173 +++++++++ .../backup/transport/restore/KVRestoreTest.kt | 221 +++++++++++ .../restore/RestoreCoordinatorTest.kt | 189 +++++++++ .../backup/transport/restore/RestoreTest.kt | 24 ++ build.gradle | 1 + 74 files changed, 4447 insertions(+), 1480 deletions(-) delete mode 100644 app/libs/commons-io-2.6.jar create mode 100644 app/src/main/java/com/stevesoltys/backup/crypto/CipherFactory.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/crypto/Crypto.kt rename app/src/main/java/com/stevesoltys/backup/{security => crypto}/KeyManager.kt (52%) create mode 100644 app/src/main/java/com/stevesoltys/backup/header/Header.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/header/HeaderReader.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/header/HeaderWriter.kt delete mode 100644 app/src/main/java/com/stevesoltys/backup/security/CipherUtil.java delete mode 100644 app/src/main/java/com/stevesoltys/backup/security/KeyGenerator.java delete mode 100644 app/src/main/java/com/stevesoltys/backup/service/PackageService.java create mode 100644 app/src/main/java/com/stevesoltys/backup/service/PackageService.kt delete mode 100644 app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt delete mode 100644 app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.java create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/backup/InputFactory.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackupPlugin.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt delete mode 100644 app/src/main/java/com/stevesoltys/backup/transport/component/BackupComponent.java delete mode 100644 app/src/main/java/com/stevesoltys/backup/transport/component/RestoreComponent.java delete mode 100644 app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java delete mode 100644 app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupConstants.java delete mode 100644 app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupState.java delete mode 100644 app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreComponent.java delete mode 100644 app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreState.java create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestore.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestorePlugin.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestorePlugin.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/restore/OutputFactory.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderFullRestorePlugin.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderKVRestorePlugin.kt create mode 100644 app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/TestUtils.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/crypto/CryptoImplTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/crypto/CryptoIntegrationTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/crypto/CryptoTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/header/HeaderWriterReaderTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/transport/restore/FullRestoreTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt create mode 100644 app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreTest.kt diff --git a/.travis.yml b/.travis.yml index c4e4e394..dd23f68d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,9 @@ +dist: trusty language: android android: components: - - build-tools-28.0.3 - - android-28 + - build-tools-28.0.3 + - android-28 licenses: - android-sdk-license-.+ diff --git a/app/build.gradle b/app/build.gradle index 0545a964..cd662b64 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -27,6 +27,14 @@ android { targetCompatibility 1.8 sourceCompatibility 1.8 } + testOptions { + unitTests.all { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } + } + } // optional signingConfigs def keystorePropertiesFile = rootProject.file("keystore.properties") @@ -43,6 +51,7 @@ android { } } buildTypes.release.signingConfig = signingConfigs.release + buildTypes.debug.signingConfig = signingConfigs.release } } @@ -70,15 +79,17 @@ preBuild.doLast { } } +// To produce these binaries, in latest AOSP source tree, run +// $ make +def aospDeps = fileTree(include: [ + // out/target/common/obj/JAVA_LIBRARIES/framework_intermediates/classes.jar + 'android.jar', + // out/target/common/obj/JAVA_LIBRARIES/core-libart_intermediates/classes.jar + 'libcore.jar' +], dir: 'libs') + dependencies { - // To produce these binaries, in latest AOSP source tree, run - // $ make - compileOnly fileTree(include: [ - // out/target/common/obj/JAVA_LIBRARIES/framework_intermediates/classes.jar - 'android.jar', - // out/target/common/obj/JAVA_LIBRARIES/core-libart_intermediates/classes.jar - 'libcore.jar' - ], dir: 'libs') + compileOnly aospDeps implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" @@ -90,4 +101,11 @@ dependencies { implementation 'com.google.android.material:material:1.0.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + + lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha' + + testImplementation aospDeps + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.5.0' + testImplementation 'io.mockk:mockk:1.9.3' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.5.0' } diff --git a/app/libs/commons-io-2.6.jar b/app/libs/commons-io-2.6.jar deleted file mode 100644 index 00556b119d45dd85a3c3073b1826916c3c60b9c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 214788 zcma%i18{BIvUa?a9oyNlZQHhO+uHGtZQHhO+qRt@Cx6bp@7;IKd;ht0SFKq!*Q)M0 zX3hS(ztNbo62Kte0l>k*0bq4yBtWO|Rmy+>07$<903ZPX07wZb@==LN3)4vPNs9>! zDJW7)31=vcS?|&!ZHSEWs8R}72;#`TXAO|E5X zmak~1&05-YWki!U5h%KNsdQsP?m4GY7cEbn1y_TU&rJ!(C#xSTuzEn&xPvav2pWRZ zMwA*2iI4m>*y42-8@M}YD1dJ}sF)T4^%N`MGdlH7@d@<#J~rmCmO9XF8zkqSKAD~d zM|59zFoLU-8IEIVfi!?8h%2f61ce_xijJp;H!CKDg84$d5>;ep4gMl$Z7>z5I)WD8 z)aTwDpsR=Aa|fbM9GVk3V!P=gJjY@4LMw75Kj+>yWXz<6mNv15vf|NDLGqMf3pxp) zqH`UMSub*m>;h<=c#K+XU1fKBk&kw^F$bLiEhEF+GSQLf;pVsltvF33|XKQ!)^PAu4aG8b2*k9x5#k+Jg@mA ziQP@i56i*}?RkjVY*y&!t8>N+Jo!xaX}SKul3|DP_*waeNBX_RP(s&_f@0iHR>kBdfmEv<(sl{7fOt-%SOddTv=W zQw8ml92q=TGPKhN8MhUq?^Vgk@8wHL1q#>M=#T+J5E_YN6nz?hBNEJE9O^O>B_K2X zIum$7f)6(CjH>2LfssreDOiyQ-IvbQP*XEn&kz2MZN%h6Ij1>VEis$V)u;Qo(HM2& zzHzHzauJrg&8fcmAw5T7;*&MR8y)Bgm^BRz9=o)c5nbLqT;u;Afv$l0|{5LmE__vx4M)qcUmS*mH zj%GI2|Ka0QtXQ`rqjLTJ}%C5HzzlGWfExoA8&# z1pWmWY6D9>2Zszr4H-l}q>rG+`^I$$dj)vpMyhIi#Qfkzd4y`sS$)KPU&=9c6p19u zCc-C~H(1VK!e~)_&)*{GQ{$BlRHTE9Tn>lh&z%lwD_1<7o}jcL1#s%L$oz0~cuzO+ zHYUx<2fHah9Ns7|hd6bL{0K8&YvB9>P(ANvqXK?Zp{Yx>8WjhoTPHx(JsG>j_*T#| z5>4ojD2F*qQ^h-FHFf1#8GDipPPa6tixIIbF=%j%JS;>A67#VOsJ!rUDJRrgA;ssr zx#p{vTB+Lw=`IeVDz#ajKnbw0PjD#rDJF*d;ppJXu3h1#C{ibk6ry1qMda$y(7Nu6 zM)ssJJ_gqkSs^_%-0oCbTa~tf*47tEMV!i&kgv80S9RFLqaDr`CaBC4D6K6aPjGVP z?JsWfAM1Z>!1|(tnJ%;%99i16f12N^7?Z?t?|n2J&_#$=Z_b*K{dkr9GzZTLkKZS z@nAi{Uedv4S+x99M?$DwyuR~KX<2^MOIpeG(5d60do=TCFHy1MgXwwq zAjvd1GJ2V#Vowx25^!khV0)E-6XggYNYon->!|5ZAm0~nl{m&0Xb9bQ`fD;iKt~tPpgE&S+65zeTfJ$zawvo_{dTpZ)BgMDgDqD8i>UWb(x!o?mps_Mdp5te&Ik z|JV7HtQ0U*ki0ALaVy2AO&Mv3=_0_%<#ST#F(1jQq70eAaua093yBgD#XYa|Gh*NF=|wT#6(UlB`Fq=OR%&_o&x2F<7NdsTaM;~W4$po zSIT8Y!j6t2r0`=m=*LSf()jbc8>5C)ZCTd9?(JtcX*(c@x#S!OG1(hl{(uo29EoL- zklb_)4^0ILj!g#9-G5O`ANo3R0)oCwM9KS-l(e;5EU z*g#K^{&H;5z%P=Ef$L2UpmEEZOHP6_w(|>_Y!y2!Z0=pxpy+Qb3!+91Uj&0dG|`wy zwF!TT2zm z6@Xzok0!yo%@@ctj{%0V-|fgJI7iGNGK;bFQzml+f@kMnn=<`rCEPAi|LsC>&Q=du zOSMnncm3N9&Y|gn=ACn;4OwZ#qBn!elZ&H#=he>lHMX@h`$~mL=zG1naW>t76}FT; zZO~1U5A6C5KAKg|r*3~zkP4e-J`L;RXY-Yaa#;xKUD)@4-?rG55HF|4PdPPoO)U@$ zl-_H%R!EO?vfPXp-kyS3u)&ob86GP*%g1Evm?YQJ+8s`gV4i}_n%G)^Ay4eBKON)C zm?v!J$&aFL6!eO`b*HAVzh`9+p>wSvU08=oYa(sDZsnq0&qrp~i7aA&{{;I4@p96% zn=E4!YvQ7z;kNcUpZ>eI-F0m)41ApZPEH2ZVR`fD^x-5pyN z|4J5NK>+}`{*!3Nr|;lsuV>)+|BYx05;k+Za39QeyM#_@(;}|*yrfm_!8u$2;-usW z*PxyoZ7=-wx-4F8 z+?MbRTQ1br+4ehS?Lf?LMM0os)-tyko_#b#8qM6^JCDE$uj4tkxsx(&E}el-93(Cw zhc-EWr4horb{di`K$XB6mZ*Clj_HfopY2g_2S+xj;m`%ivl?rF z)T)I5Lj|f8vu^B-=&qZP4O&TM>He~>!=7-35}q#E4$;`8sWjQ)gb{a@ zzQ`*0`pAglvI1e&G-8Ui=8K>XMR3%@#45c>67p*%eFX0>^} z1>~dNGO?w(hgKMj%GpbsQ+~`5PCT{62{b)i6;8yYT?PCu*IqITOKdN`mrerkk_-x& zOp#E9wPgKIS)F?MfsD7&W`sKktwP*y5Rz-jcC=w^y@Nm3@b*8*`Ox0Z4X?J#zV+H# zfOUeGQu`~(P7Kko&1pZe%5!%K%MekQUQkM`=M8*Hw6i}+bc4xcRWSVyk?|lCkU4f` z^s(Ml0CVk|)@TGQf)Idxd8&^V<{{7z0op>FIWTclhvh-KMDwsg5>_us`(DH31Lg27 z0!J_S>KC*JNJo%Nq3O4gf0+NzM*ge$4#PSSl)^eN47rj^9gb3^oo6kdo47pa}EwZAQr(ifiCdC7>W%7d6dk9LgO@!!ywl zx6VX_I0J`3P_*@coA-#uAM+zSVlH6YJyB#D_K#*jdEc;_goHh`p3rb$^4slirPBum{< zBZJFm6+h0emc2)p-kTyu!6MD$+YRyn6yc>Xh%B&~^yo??OM5#%gzYs+YN+6U5UVsi zntZ4<(5TtcU6RCli$TsQ0Ru@erV_&z0AL0*0fe^Om2*n`p zVwWD1`cm%4MjQ8oK|)|qReFHhSXJ16V1GpkY%~sq;7W>;V?%^s5Nu5N(7ubVf_6m| znlf9Nooy*+%WY851D-Va*Wc z9^ESHNb13y^%@6a)Pk$~?-Z5sbRV9X7!=d&+%|S7t&qrrsmvg!`zDL`CgQ{^YRnwE z7dxh(6Ow%lzO$^^LDdENnw&wYS}oJ5uJhD4X@;#~tNrEAWXvntUbr(9*!ZQ~(sTH~ zd$}!F5;hU_=IN9fefx{=t*XycKhD}^9iXnJ_SbEE0NSy!DJSFU3*)>D8YtHcaoE{w zv_j#1BOpTx9M~8v_9NqK8Qgw%9+?lOQ{M&ZanE*b)UMqnS~0ktc*qY4b5Lx@6tP>qrGk`fkNzC|qmz<$4*N!WnvIg|AGwWtV7VXP2(%5n{ zF%KYA2PPcCls3({&poF;tY#wOO@33?CCXKeAgKv`UGVJmQtd;`X^9#Oh`09lFit!j zU!NbnpHH<_F)m%qSJwz$385aX;KbhT@`)--M9Of%vn~r;Bh3@{Lc_!23vFXQ!pKzW zIgk!Q{%+X@`J1liO_PP_s(0xDpvL1msvp<|zCG(aRfKefZHiaR1uj!WFzB{S_6!^> zAD)+{mvtokMa>tme1PoGQ9aPc#iwkCrGy;3Ax_Y9Ima9iNB)WFT(Rq@7||7{4a;wr zY7kt3CGG9|3@{S32-&?B-{`XStwZrhgB{LHe!DYt1ShkWKT5JdHxKNFl1PblAvt{% zm`VeGu9iw7awHKkw1tgs2UMfpZ}!#wgvD)WqmYI`V~TE;vIo)Hr)?ffxV`lh(?O6i zn;L0_^9v&^-bkg9JH`rghiEB%>4nwt)(VJSf%8MCr{G&aZQaNBes@@pVn~D=tMdZt zd~y@q+vuQZ1!j0SK=8{Svx;SRb~KZedf@3gqtz=vRi#I{&s|6ka53?Y$lPIXV)E15 zH5Nu7OBy>B7@TIz&1X*Mv2la^6XZ>JndRtnEE(dLXOBcZo2S~ZSUYk$bLKxd zaEf%z=&C$hACqa5MuE3M*_u20MN}ItlMb~$M3T$juR=6?o$_mV$|(fuTB`)k4yRn! zo{LAHCPwG@?hHYYeSVGd344(M;SS-sgF1%cA*8=u@FRapM*>c)Rd7wQI6Ui zAZ+y%539FF)6_m!|7x`U9kB5$JNL3Rzi{+4&G=qS^`cmGA!+jRjeSmMdP&-(^!@h; z9wA=w!;a-|HIJ%>&W8(|3q)@>M;Aa|3gho~P=F+*bjST*P$Ov0+s%l|?ZgK8(Z3P( zS;?H!?YEssHoMJk><0mjkcHuq`jW$`q>Is9@B<*wQxT2O5CBOA&dY)IzTv|N4y@&e zCx?@v+lZ4&7u!iez?R(vpoGCZ!-_`3h>@N~3L&;4Ec{Bam2dF+y>!Ks*v zLg*gIbiKi-{s0kiy{atAQ5mSj-HP#u%@W2YI>*;1$QU_*AoRou+IR@~y#HMYLI_X` zBJ)C_5Gr~QB78VlqPTcUAn$rP#%AXvL|n>MObhbWy&=F_HOwJGGJRs>ejfj4n#1o) zZ5RWZcSvA$($eD*R;|WE;)yc?A4?6eD>I^zZN1-|3Vua(1}c7hPU1<|e(2F-yZ0P- z={*n-iF6Sn_3R!L$S6wq<*kGyLD$va-9~i5e&I+6#mXvgMaziA4i3r4=8fFgC4`VC zU94v1qmfB8)`OVfCWe0tluz-zu)kr3vIIdAL~VR8q)eifVTlsy4GeP~3v9ZH40q9Tg}sV1M&$;$Dr=y)KJ@$X zOS3s(%&;|6hTuoy%nO!EU!AK{1o3?{!aPIAC<+q-ZKe8!e(6|gR=$!swZufnc>@fr$0clV#{xxKqy_w?{7P^k|(5D$6oWxyrJH+*2rSQqO z@eis=!2~(nXX5l+7`Mx${%;2m*ST&%s%UGirZ@AiNL;Kn^M`6Go1YyZD}nZ+3$GD4 z4HOCr=J76W3Y|7%q3=QL-uf&)KWJ2|aWLh^+_1ZrL6uY@>`K?fjbfH)2|8Fm(cR?$%AVfh zm`+%(KrxNyDm^n}9n@wsJCi9iMD7oB1@w%IsvUOZ!AiLq=+>;fUjRlwWOs{z?*oCt z5<;GCG@z#+WoSE>2{vyHgiwdvOh(ttmN6hDM$LkKk2C;c-%O_Nm$ZAxnWLo|la_>L ziF!m099!*Z-?+Bz5*1@wm2%7?j9lPoSkuTpkopL-D2nUkBCF~1jdX;D#3Cr2`sRYZ zW}^BMHsy!_;noh!2-_?jn^>AoLas5Oxlgb?yx$8f#H3bqJxNTzbTH{;Z9rL^Y|p_M zMPwBprmM?LuV|rmPT88GNn1clp~dQz+QXnA!y@p`Rwr=6-C>Y;!}YD`z&Q2pyE$aD zpSUM~c~)&DMfq;2T+%OO75#`AEI;2w$3Uv%LBPCBl>2M35Pc@fsO{Yq3HgPsaEfRg z$tK6|W@#=c1zuCoEKW!|^iNKu&lIcHaPv+d3O7<1_EKQ`jg0%u?fSfpMtqeBBCjUK7wPt{f26b)W=aO^$Z?R zZQVB#266PtHmM64Kq*gPt2{dNvV<0zPIgmkLJ3@Undv6sZFc&^`$ zMozr6&0%L(%MAQ+4wXn+50cfY{fA$tZOALY6MxE)81Rz6e;Z)Lq3d_N=lMsc@=w(B zS5jgj=&&*R>f9`RWiCvAo3;FjloZU||BOOOyaTd1s)vh?WM+j4b zIgv_pVh4@7Y#X_tVzs$&KQ-b^yi&DUPj90)s9RNq*ca7$BpzPL5)~MWww`frca=WZHjak{8&cjuKjy<^tu> ze85+?TG2o$X*grB%n}Bt0__#p^HAUC8aG@Somc-5;ouvcDd~&^QEeuRw&?HZUrGm+ zZWsNG6}PhmJ6fnAv%S2+NF$uN?_J1Wu^1!mxJfY=B9Pj!8p%}`tl2m#9I#Rg-!&!H zcf{a~FO{F@F_UCX?vD@DfH`fZidNI!Ekyw(hC%FDgzUvmVNLEK`sQ+tvnMX@mL;y1 z{le3PPy3jKUbeQuc=L9zyZEG2W_qlF&Uva6S<~kU8Xl|uEK!ihpyGYBE$#-*kIpog zI9XbvF>^9(Dx!OsH5=6o*0LNL<`CK9tr&VY|Ry+-6P? zPyCs|=W7zPsI&@JphTAJ0mpNiU&rYX-;E4)EWd`LJz(LpC~bkJbpDpTj7n)N zk3#M-j1<)M0Yf08x9*_E_Nq~E4I+?GLQ;(soWmzw!&yC<4-K?w2+Ks@IS}gvtv(ZYhx6wY|3U4eZ+Tag#LqR9g>9b!d+aBM*I(6Z8QpV=JxqlK`1S| zPFRb!zNi={hle6oXB$`;66V@4IhHAZMG@o$t?-tilB?eL9k?puTT|@iu{R%;%=S`O z|76trNczdrK($nx(}Br%h(G-J&))i1KkmVFtOfWgJXL)8GRuGB$D(G2hDO%^duy2> zFZD;Q)N8HZS$}e1HAS#4|AK z;4-MWnqb054g63eG)|XOsB?(EePgtK2-sMZM-P6F$FsYpA3R&AX!qDf z+FRzd%hqTewa?s-zXL7;H>lw)(N}2-XpUtB4LM|*$d;x3G5lxjQzs2v!eYzEc!^=r z=?m<`@Y>LwLHg8Gz;b8K8inq|o4i(2oCKok?c_yoY#GwdN0M{cJ3k`N9R+Rq{U^C( z&Pkvch#r}K_8|)ZnjfIXtJ$F!N@vpUFLM1PUA7GB7*==&Z@&KN8;Y-<+}G~Q1leSp zwli5`q@FwoC16BZ15cb+&ZbB?PHyf*>6C{}0{o#d#YuGqi37e>&pM8@t+puAQ zdznw1Y}z~)6bIfB_-MEe43|8t+8@Awfb7pu`YXsx z^dy_}zcLtCC;$M?{{&=GdM0KD(oRaDpf8^KG9S|)+piopui9)rkDBHHn0JTR7k9K< zpiWhzUKu@^{P3{#pw5B~euC(PZJ)7U4guf3MF@+h+N00J^c2HDseBVN?BH4h?WguFw93=~mt;X>%*a*xoI1}%{j7cV2LvQAiCjtH8dwKs z0y+Kd0(ne;=-a@OMy2Y@+jr4u@l6Bd6!Eo90}QlxtzAw+SFY+p-KYc5p&yTA^~~t0 zcI45mlD4m>S!!LXBa?cc}5NcniQ6&8;m!zLfYo2yxSn5|-1*O?r*=;MG zV-VhHK79^krXE zC{G<>-ID}xsPNZKcr|)3;+x!{B&at4o_0$9`OFR_xfbh8N3`XzwrB0$?y)EMGvmti z`aM=q^@45)Yju!S9vu=0%eSmhErP&q76!hC#o95Phq4}h9>q(GyjDd}eux$2RITNh zv2Tc{oStlh)CKf=g;V7PO$lTcUh|`=LL|vX>#i-u7QuCv1P02oX7x%T&!8InZ>2DM zf_T;TmWp$a6CvSAMOGuHCDktbJ_!))9_H2V^ZC^jLOC_tze`|rI85N?aYT3gjIasC zihc8fNJs>{25-I#TF{G#yoNouPvzDHx8$xCuI$S58rg*UUgWpsBZl+(I~ALD# z)xwHly1GDi_B#>CXgt4{6D-7*43Y)2#Lre}It@%Jyun%TS-Yu0T|CieJEwtDAT1xJ z>N+{}L?WE8xb?7;>t-NVoB0KbHIWp-iNE7WnZV(MF3RNWXqJLpxCX+n+Y+AWIxvI_ECzz#y+phifug&QvET(`e^=w$$y83C&aJI4@lrz4SM5rF>`w zSm*PPu;>NI&lT0!MoM5-`<(rF>K1CfXTEqM`0Q`*%XLi>y1|)n%|d@cG&%mZ9LS^6 z(%`XJv5{aoy1{AfG~*jMTVV|*(xT*oxfQ>|nbqo3CpntCM;b%P9jTMm6Pu&R$R1eW zr60wZVeJ^8(PBZar)EK^$?xFd<|o4Jj8}T|<&WZ-C4+ zc5eN;H9jIxhT!+lVdnsY9j=<|mdIhvnZZWYJ>(hHeNCNXC*f+?M_nJgwC+4tKS!oL zCb3kbcNyhLlH)Q?3HR86Ta4#8SD}o&w>Y=NiKGEKA(9qx^+`Dv6o+?;0&c?Dt>xmM zCLG@V#XrZ}WP-gyUb19g6?S#-2a5!sqKA`$Z(*7Hx|BApjqr$UeFl<@&sp@6Q#nR7 zMq)c5b>ys^yqU|6X8OV*6UOpXSp~i^C5&xgxHH|;mH0^wW#GJ^o*FMh+-*+5acFv# zJh|Mk2y9{SIOMUnR*BbCdQ!1@@cKUuR5%XaF=}IMc!9A%)o@s+_l|BVIx0bwT}fo{ zd5RKeOmGIKAyKpn$Jk?696TF4QSU%@rDQOdJ+TV{Lvc4L3RX)Wn<7F^Q$wxerIr%Q zx)3RXop3D|5zBVO=(tVb?iEg*-<3D}?B@6bLAPpm=6%SAoG zcmZB@)mZ-ai8Ij=z2P}10`^>3dn2M)D8Gkn0UFZ}_M?*o_+awvIKy+X#4*xY5A!I0 z`D5UO_Aja==?al-e-p@r>|xp5cnm_bjOx+?cM067uWnWF!e zu2Zf3{*29)2GXwuRM!Ui9BZ(jkrAZDY|%Kt;f?-!^MVL;gEz+9q}nI|YW{f10)uf< zYp`;nUAAA2n#`mNeFR;k$q33`gynLN653~GFh3lgd?JaLlowO4Bq}kE7npK+vbBq; z+U4uofUUe~&kP=rrgDiC?1)+`n7L+gFwtpmJxyuN0%mnd=njegqBo6M*)b8!pdVxW zGV1&q2G|C4msaFX9KoWv#>%K(!{E_PNB^x6BhS17|E;~du;i<6s-w=7YEsNBU}q-a z0K593R9;%|wc&h7M4)F_7gq#y!9YZ_a;%IOOlPv}*a$L5J=1RQzUw5j_C|A}titFJ z=zsX>pa;vARA^htM33$e51)Eu1PYk%6B`K)dM>o@2fm<>p$(@>C$yxsBJ9n|wT)Eq zR?Q!_ACX=fY*qv$SF}9|ilPXlOeM#$e*=E{2z$xGKtI~M`xCYRY)n8v-h75Jyt3BF z1RboA7@Ef1HRJ;tX>=ewlpGkYyg|97f*=A@%`t9iNsSWY*FN^CsD1(`4}P`htbCMJ zT3^tH8W#1@czd0jo>&nQ->rP&C;gak<>UFALRzQ%S#M10w z)mlSh>H3CgPM$t<>C;DFZHIP=S!1-8hTAPw*g<982jl~eZxYfo!>oK2Oe*ucXVDFY z?a&;>&DpW(z^8IGFl`%l(oSmhb;K<0#ip#Fz&tC$~;S#tNH_B~! zrnpDj2s=c*1+1hNg1ebfg%y6-SKo$E961D*Gv%vyO8k7jAJTK@D0RXN;guR>j>xqH zSg|a!`t*l1gl~9rWmJ5q6Hhnn!lo1VI!w;X6jZu2m(!Z`?pegh%QMc5ETTru=%Ui@ zO)T##tnIOftpgofe$X5QOT1Yc`~DH$g0f_%8GdF543hc`Ng9wKI{f{Ryx#L!Gq_t5zPhj;* zTefopST4d1#c|fNX@mI|xTfvAfW1L;poh!0bkxcPPF?emr8Brm5w9Acyj_;!I?2MG ziPRu)S(5G%ok+Jp#@RC_O#W_PxEwsB?Dy`a*XxJ5+q})Os1eESyluA|au@u!#@3)S zkj6^(C^x?-DFPA*-z(ZK8X;mx{`InubS{P)ho|zX;feJ=RX-88_o$ln%Hn~J?B_;gpP@$#iZM2t+-NK4OBHGu~;*& zX8x-pvwLmB!Oj2_EWCz1N`He;ZAfgL(P(jZc%>a=cPkt&wzgl-Sf#|R<3yt>b427Q z9(-6WK46J2y_??ohPgHP)2=vQBh?ax z)Oe|??qKStSd}rqrULs@LayRMhyz_=#<=g^El4P~t%y%Kl7G3_C4@t7n)t}vo+B;p zGAeuuobPYG*QfLpbzY?{qq|%aj(@b^wAp5f=fl%{mpeDmySSk~QJ#PEHNyBE8i+XF z9$dQA!B0T?s{;`P;jPs)q3pt zsPWvdyKUen*;z3duw2}Mz`&HOpC8Z8JYAbS0Gb9E`Utz>w6SI{*$x9s6e7)``e*cb zTZXr#g8E^-sq&^2i9ndRz6H$k%%PH$j)USL!aYsM!!BePj{E5+OJAq8J6h!5-K!jWf7@S7V}pvEDSf< zY0kI;(s=fMW*kPDu{xO1*LbcJ_dh}e;G80Y1OS6*!okd#a2YoIBDnm9#{5Dj-T45o zvRBBy@sDY^KR?R9K4g{AwI9u2O+$MM008FyWTWvvPgcp%%+leXUBeDFGhIz32u$4 zOUnRS3no_IFbE(xlO%$RG!7%lGeet5^q%zF$Mi)=Ahbx&hjCipwvcTb1F;0IBZ=iM zhZtv0Q{EEE?e?o5wkS*P8zZp9)$Je`#3u8<^($V+->jjl>0|JWwUi7a%n{KEATWT| zdadP_0rw>H;LEOIMBXorY8Y+#bSEUS`#xqM~12HSAXR%E3t;3$M+skSi2NPTn z^mNcTdVUKV5+qL$zfQ(rdPjQBXQCo$c+D`g$A9c7^+hm@2=sP*4+e*%^O+2xrFQCQ z3*+vwQrQHl7;(41p^*tLVkSOB_}lq9`)1pgLRLngvE*HIzOS%Q`OKk7%qgk>Tb|1zuVqA#%N{pjZvBuB+LF}e-q|Boc(cd zRsj#R5`%UajnStE)&|Mf0}hoW(5x93MeHpDOryRh8M>

M`<`y}6#-im4-#}g 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%0AvVBjm&;RJ>Kn<2&;(OV+wD=TU;oOZ`se%a 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 maxSegmentLength) { + throw SecurityException("Segment length too long: ${segmentHeader.segmentLength} > $maxSegmentLength") + } + + val buffer = ByteArray(segmentHeader.segmentLength.toInt()) + val bytesRead = inputStream.read(buffer) + if (bytesRead == -1) throw EOFException() + if (bytesRead != buffer.size) throw IOException() + val cipher = cipherFactory.createDecryptionCipher(segmentHeader.nonce) + + return cipher.doFinal(buffer) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/security/KeyManager.kt b/app/src/main/java/com/stevesoltys/backup/crypto/KeyManager.kt similarity index 52% rename from app/src/main/java/com/stevesoltys/backup/security/KeyManager.kt rename to app/src/main/java/com/stevesoltys/backup/crypto/KeyManager.kt index 319e219c..793e34d2 100644 --- a/app/src/main/java/com/stevesoltys/backup/security/KeyManager.kt +++ b/app/src/main/java/com/stevesoltys/backup/crypto/KeyManager.kt @@ -1,4 +1,4 @@ -package com.stevesoltys.backup.security +package com.stevesoltys.backup.crypto import android.os.Build.VERSION.SDK_INT import android.security.keystore.KeyProperties.* @@ -8,11 +8,34 @@ import java.security.KeyStore.SecretKeyEntry import javax.crypto.SecretKey import javax.crypto.spec.SecretKeySpec -private const val KEY_SIZE = 256 +internal const val KEY_SIZE = 256 +private const val KEY_SIZE_BYTES = KEY_SIZE / 8 private const val KEY_ALIAS = "com.stevesoltys.backup" private const val ANDROID_KEY_STORE = "AndroidKeyStore" -object KeyManager { +interface KeyManager { + /** + * Store a new backup key derived from the given [seed]. + * + * The seed needs to be larger or equal to [KEY_SIZE_BYTES]. + */ + fun storeBackupKey(seed: ByteArray) + + /** + * @return true if a backup key already exists in the [KeyStore]. + */ + fun hasBackupKey(): Boolean + + /** + * Returns the backup key, so it can be used for encryption or decryption. + * + * Note that any attempt to export the key will return null or an empty [ByteArray], + * because the key can not leave the [KeyStore]'s hardware security module. + */ + fun getBackupKey(): SecretKey +} + +class KeyManagerImpl : KeyManager { private val keyStore by lazy { KeyStore.getInstance(ANDROID_KEY_STORE).apply { @@ -20,18 +43,18 @@ object KeyManager { } } - fun storeBackupKey(seed: ByteArray) { - if (seed.size < KEY_SIZE / 8) throw IllegalArgumentException() + override fun storeBackupKey(seed: ByteArray) { + if (seed.size < KEY_SIZE_BYTES) throw IllegalArgumentException() // TODO check if using first 256 of 512 bytes produced by PBKDF2WithHmacSHA512 is safe! - val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE / 8, "AES") + val secretKeySpec = SecretKeySpec(seed, 0, KEY_SIZE_BYTES, "AES") val ksEntry = SecretKeyEntry(secretKeySpec) keyStore.setEntry(KEY_ALIAS, ksEntry, getKeyProtection()) } - fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS) && + override fun hasBackupKey() = keyStore.containsAlias(KEY_ALIAS) && keyStore.entryInstanceOf(KEY_ALIAS, SecretKeyEntry::class.java) - fun getBackupKey(): SecretKey { + override fun getBackupKey(): SecretKey { val ksEntry = keyStore.getEntry(KEY_ALIAS, null) as SecretKeyEntry return ksEntry.secretKey } @@ -41,6 +64,7 @@ object KeyManager { .setBlockModes(BLOCK_MODE_GCM) .setEncryptionPaddings(ENCRYPTION_PADDING_NONE) .setRandomizedEncryptionRequired(true) + // unlocking is required only for decryption, so when restoring from backup if (SDK_INT >= 28) builder.setUnlockedDeviceRequired(true) return builder.build() } diff --git a/app/src/main/java/com/stevesoltys/backup/header/Header.kt b/app/src/main/java/com/stevesoltys/backup/header/Header.kt new file mode 100644 index 00000000..806b6afa --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/header/Header.kt @@ -0,0 +1,43 @@ +package com.stevesoltys.backup.header + +import java.nio.charset.Charset + +internal const val VERSION: Byte = 0 +internal const val MAX_PACKAGE_LENGTH_SIZE = 255 +internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE +internal const val MAX_VERSION_HEADER_SIZE = 1 + Short.SIZE_BYTES * 2 + MAX_PACKAGE_LENGTH_SIZE + MAX_KEY_LENGTH_SIZE + +/** + * After the first version byte of each backup stream + * must follow followed this header encrypted with authentication. + */ +data class VersionHeader( + internal val version: Byte = VERSION, // 1 byte + internal val packageName: String, // ?? bytes (max 255) + internal val key: String? = null // ?? bytes +) { + init { + check(packageName.length <= MAX_PACKAGE_LENGTH_SIZE) + key?.let { check(key.length <= MAX_KEY_LENGTH_SIZE) } + } +} + + +internal const val SEGMENT_LENGTH_SIZE: Int = Short.SIZE_BYTES +internal const val MAX_SEGMENT_LENGTH: Int = Short.MAX_VALUE.toInt() +internal const val IV_SIZE: Int = 12 +internal const val SEGMENT_HEADER_SIZE = SEGMENT_LENGTH_SIZE + IV_SIZE + +/** + * Each data segment must start with this header + */ +class SegmentHeader( + internal val segmentLength: Short, // 2 bytes + internal val nonce: ByteArray // 12 bytes +) { + init { + check(nonce.size == IV_SIZE) + } +} + +val Utf8: Charset = Charset.forName("UTF-8") diff --git a/app/src/main/java/com/stevesoltys/backup/header/HeaderReader.kt b/app/src/main/java/com/stevesoltys/backup/header/HeaderReader.kt new file mode 100644 index 00000000..2cf0b4a6 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/header/HeaderReader.kt @@ -0,0 +1,72 @@ +package com.stevesoltys.backup.header + +import java.io.EOFException +import java.io.IOException +import java.io.InputStream +import java.nio.ByteBuffer + +interface HeaderReader { + @Throws(IOException::class, UnsupportedVersionException::class) + fun readVersion(inputStream: InputStream): Byte + + @Throws(SecurityException::class) + fun getVersionHeader(byteArray: ByteArray): VersionHeader + + @Throws(EOFException::class, IOException::class) + fun readSegmentHeader(inputStream: InputStream): SegmentHeader +} + +internal class HeaderReaderImpl : HeaderReader { + + @Throws(IOException::class, UnsupportedVersionException::class) + override fun readVersion(inputStream: InputStream): Byte { + val version = inputStream.read().toByte() + if (version < 0) throw IOException() + if (version > VERSION) throw UnsupportedVersionException(version) + return version + } + + override fun getVersionHeader(byteArray: ByteArray): VersionHeader { + val buffer = ByteBuffer.wrap(byteArray) + val version = buffer.get() + + val packageLength = buffer.short.toInt() + if (packageLength <= 0) throw SecurityException("Invalid package length: $packageLength") + if (packageLength > MAX_PACKAGE_LENGTH_SIZE) throw SecurityException("Too large package length: $packageLength") + if (packageLength > buffer.remaining()) throw SecurityException("Not enough bytes for package name") + val packageName = ByteArray(packageLength) + .apply { buffer.get(this) } + .toString(Utf8) + + val keyLength = buffer.short.toInt() + if (keyLength < 0) throw SecurityException("Invalid key length: $keyLength") + if (keyLength > MAX_KEY_LENGTH_SIZE) throw SecurityException("Too large key length: $keyLength") + if (keyLength > buffer.remaining()) throw SecurityException("Not enough bytes for key") + val key = if (keyLength == 0) null else ByteArray(keyLength) + .apply { buffer.get(this) } + .toString(Utf8) + + if (buffer.remaining() != 0) throw SecurityException("Found extra bytes in header") + + return VersionHeader(version, packageName, key) + } + + @Throws(EOFException::class, IOException::class) + override fun readSegmentHeader(inputStream: InputStream): SegmentHeader { + val buffer = ByteArray(SEGMENT_HEADER_SIZE) + val bytesRead = inputStream.read(buffer) + if (bytesRead == -1) throw EOFException() + if (bytesRead != SEGMENT_HEADER_SIZE) { + throw IOException("Read $bytesRead bytes, but expected $SEGMENT_HEADER_SIZE") + } + + val segmentLength = ByteBuffer.wrap(buffer, 0, SEGMENT_LENGTH_SIZE).short + if (segmentLength <= 0) throw IOException() + val nonce = buffer.copyOfRange(SEGMENT_LENGTH_SIZE, buffer.size) + + return SegmentHeader(segmentLength, nonce) + } + +} + +class UnsupportedVersionException(val version: Byte) : IOException() diff --git a/app/src/main/java/com/stevesoltys/backup/header/HeaderWriter.kt b/app/src/main/java/com/stevesoltys/backup/header/HeaderWriter.kt new file mode 100644 index 00000000..36f93b53 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/header/HeaderWriter.kt @@ -0,0 +1,50 @@ +package com.stevesoltys.backup.header + +import java.io.IOException +import java.io.OutputStream +import java.nio.ByteBuffer + +interface HeaderWriter { + @Throws(IOException::class) + fun writeVersion(outputStream: OutputStream, header: VersionHeader) + + fun getEncodedVersionHeader(header: VersionHeader): ByteArray + + @Throws(IOException::class) + fun writeSegmentHeader(outputStream: OutputStream, header: SegmentHeader) +} + +internal class HeaderWriterImpl : HeaderWriter { + + @Throws(IOException::class) + override fun writeVersion(outputStream: OutputStream, header: VersionHeader) { + val headerBytes = ByteArray(1) + headerBytes[0] = header.version + outputStream.write(headerBytes) + } + + override fun getEncodedVersionHeader(header: VersionHeader): ByteArray { + val packageBytes = header.packageName.toByteArray(Utf8) + val keyBytes = header.key?.toByteArray(Utf8) + val size = 1 + 2 + packageBytes.size + 2 + (keyBytes?.size ?: 0) + return ByteBuffer.allocate(size).apply { + put(header.version) + putShort(packageBytes.size.toShort()) + put(packageBytes) + if (keyBytes == null) { + putShort(0.toShort()) + } else { + putShort(keyBytes.size.toShort()) + put(keyBytes) + } + }.array() + } + + override fun writeSegmentHeader(outputStream: OutputStream, header: SegmentHeader) { + val buffer = ByteBuffer.allocate(SEGMENT_HEADER_SIZE) + .putShort(header.segmentLength) + .put(header.nonce) + outputStream.write(buffer.array()) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/security/CipherUtil.java b/app/src/main/java/com/stevesoltys/backup/security/CipherUtil.java deleted file mode 100644 index 868930f2..00000000 --- a/app/src/main/java/com/stevesoltys/backup/security/CipherUtil.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.stevesoltys.backup.security; - -import javax.crypto.*; -import javax.crypto.spec.IvParameterSpec; -import java.security.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; - -/** - * A utility class for encrypting and decrypting data using a {@link Cipher}. - * - * @author Steve Soltys - */ -public class CipherUtil { - - /** - * The cipher algorithm. - */ - public static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; - - /** - * . - * Encrypts the given payload using the provided secret key. - * - * @param payload The payload. - * @param secretKey The secret key. - * @param iv The initialization vector. - */ - public static byte[] encrypt(byte[] payload, SecretKey secretKey, byte[] iv) throws NoSuchPaddingException, - NoSuchAlgorithmException, BadPaddingException, IllegalBlockSizeException, - InvalidAlgorithmParameterException, InvalidKeyException { - - return startEncrypt(secretKey, iv).doFinal(payload); - } - - /** - * Initializes a cipher in {@link Cipher#ENCRYPT_MODE}. - * - * @param secretKey The secret key. - * @param iv The initialization vector. - * @return The initialized cipher. - */ - public static Cipher startEncrypt(SecretKey secretKey, byte[] iv) throws NoSuchPaddingException, - NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException { - - Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); - cipher.init(Cipher.ENCRYPT_MODE, secretKey, new IvParameterSpec(iv)); - return cipher; - } - - /** - * Decrypts the given payload using the provided secret key. - * - * @param payload The payload. - * @param secretKey The secret key. - * @param iv The initialization vector. - */ - public static byte[] decrypt(byte[] payload, SecretKey secretKey, byte[] iv) throws NoSuchPaddingException, - NoSuchAlgorithmException, BadPaddingException, IllegalBlockSizeException, - InvalidAlgorithmParameterException, InvalidKeyException { - - return startDecrypt(secretKey, iv).doFinal(payload); - } - - /** - * Initializes a cipher in {@link Cipher#DECRYPT_MODE}. - * - * @param secretKey The secret key. - * @param iv The initialization vector. - * @return The initialized cipher. - */ - public static Cipher startDecrypt(SecretKey secretKey, byte[] iv) throws NoSuchPaddingException, - NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException { - - Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); - cipher.init(Cipher.DECRYPT_MODE, secretKey, new IvParameterSpec(iv)); - return cipher; - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/security/KeyGenerator.java b/app/src/main/java/com/stevesoltys/backup/security/KeyGenerator.java deleted file mode 100644 index 9323e7b3..00000000 --- a/app/src/main/java/com/stevesoltys/backup/security/KeyGenerator.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.stevesoltys.backup.security; - -import javax.crypto.SecretKey; -import javax.crypto.SecretKeyFactory; -import javax.crypto.spec.PBEKeySpec; -import javax.crypto.spec.SecretKeySpec; -import java.security.NoSuchAlgorithmException; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.KeySpec; - -/** - * A utility class which can be used for generating an AES secret key using PBKDF2. - * - * @author Steve Soltys - */ -public class KeyGenerator { - - /** - * The number of iterations for key generation. - */ - private static final int ITERATIONS = 32767; - - /** - * The generated key length. - */ - private static final int KEY_LENGTH = 256; - - /** - * Generates an AES secret key using PBKDF2. - * - * @param password The password. - * @param salt The salt. - * @return The generated key. - */ - public static SecretKey generate(String password, byte[] salt) - throws NoSuchAlgorithmException, InvalidKeySpecException { - - SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); - KeySpec keySpec = new PBEKeySpec(password.toCharArray(), salt, ITERATIONS, KEY_LENGTH); - - SecretKey secretKey = secretKeyFactory.generateSecret(keySpec); - return new SecretKeySpec(secretKey.getEncoded(), "AES"); - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/service/PackageService.java b/app/src/main/java/com/stevesoltys/backup/service/PackageService.java deleted file mode 100644 index 0b7560a6..00000000 --- a/app/src/main/java/com/stevesoltys/backup/service/PackageService.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.stevesoltys.backup.service; - -import android.app.backup.IBackupManager; -import android.content.pm.IPackageManager; -import android.content.pm.PackageInfo; -import android.os.RemoteException; -import android.os.ServiceManager; -import android.os.UserHandle; - -import java.util.List; -import java.util.Set; - -import static com.google.android.collect.Sets.newArraySet; - -/** - * @author Steve Soltys - */ -public class PackageService { - - private final IBackupManager backupManager; - - private final IPackageManager packageManager; - - private static final Set IGNORED_PACKAGES = newArraySet( - "com.android.externalstorage", - "com.android.providers.downloads.ui", - "com.android.providers.downloads", - "com.android.providers.media", - "com.android.providers.calendar", - "com.android.providers.contacts", - "com.stevesoltys.backup" - ); - - public PackageService() { - backupManager = IBackupManager.Stub.asInterface(ServiceManager.getService("backup")); - packageManager = IPackageManager.Stub.asInterface(ServiceManager.getService("package")); - } - - public String[] getEligiblePackages() throws RemoteException { - List packages = packageManager.getInstalledPackages(0, UserHandle.USER_SYSTEM).getList(); - String[] packageArray = packages.stream() - .map(packageInfo -> packageInfo.packageName) - .filter(packageName -> !IGNORED_PACKAGES.contains(packageName)) - .toArray(String[]::new); - - return backupManager.filterAppsEligibleForBackup(packageArray); - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/service/PackageService.kt b/app/src/main/java/com/stevesoltys/backup/service/PackageService.kt new file mode 100644 index 00000000..91dea0cf --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/service/PackageService.kt @@ -0,0 +1,57 @@ +package com.stevesoltys.backup.service + +import android.content.pm.IPackageManager +import android.content.pm.PackageInfo +import android.os.RemoteException +import android.os.ServiceManager.getService +import android.os.UserHandle +import android.util.Log +import com.google.android.collect.Sets.newArraySet +import com.stevesoltys.backup.Backup +import java.util.* + +private val TAG = PackageService::class.java.simpleName + +private val IGNORED_PACKAGES = newArraySet( + "com.android.externalstorage", + "com.android.providers.downloads.ui", + "com.android.providers.downloads", + "com.android.providers.media", + "com.android.providers.calendar", + "com.android.providers.contacts", + "com.stevesoltys.backup" +) + +/** + * @author Steve Soltys + * @author Torsten Grote + */ +class PackageService { + + private val backupManager = Backup.backupManager + private val packageManager: IPackageManager = IPackageManager.Stub.asInterface(getService("package")) + + val eligiblePackages: Array + @Throws(RemoteException::class) + get() { + val packages: List = packageManager.getInstalledPackages(0, UserHandle.USER_SYSTEM).list as List + val packageList = packages + .map { packageInfo -> packageInfo.packageName } + .filter { packageName -> !IGNORED_PACKAGES.contains(packageName) } + .sorted() + + Log.d(TAG, "Got ${packageList.size} packages: $packageList") + + // TODO why is this filtering out so much? + val eligibleApps = backupManager.filterAppsEligibleForBackup(packageList.toTypedArray()) + + Log.d(TAG, "Filtering left ${eligibleApps.size} eligible packages: ${Arrays.toString(eligibleApps)}") + + // add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data + val packageArray = eligibleApps.toMutableList() + packageArray.add("@pm@") + + return packageArray.toTypedArray() + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/service/backup/BackupObserver.java b/app/src/main/java/com/stevesoltys/backup/service/backup/BackupObserver.java index a319b377..8bcbfefa 100644 --- a/app/src/main/java/com/stevesoltys/backup/service/backup/BackupObserver.java +++ b/app/src/main/java/com/stevesoltys/backup/service/backup/BackupObserver.java @@ -12,8 +12,6 @@ import com.stevesoltys.backup.session.backup.BackupResult; import com.stevesoltys.backup.session.backup.BackupSession; import com.stevesoltys.backup.session.backup.BackupSessionObserver; -import static com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport; - /** * @author Steve Soltys */ @@ -61,9 +59,6 @@ class BackupObserver implements BackupSessionObserver { @Override public void backupSessionCompleted(BackupSession backupSession, BackupResult backupResult) { - - if (backupResult == BackupResult.SUCCESS) getBackupTransport(context).backupFinished(); - context.runOnUiThread(() -> { if (backupResult == BackupResult.SUCCESS) { Toast.makeText(context, R.string.backup_success, Toast.LENGTH_LONG).show(); diff --git a/app/src/main/java/com/stevesoltys/backup/service/restore/RestoreService.java b/app/src/main/java/com/stevesoltys/backup/service/restore/RestoreService.java index 6441397a..8e966b8f 100644 --- a/app/src/main/java/com/stevesoltys/backup/service/restore/RestoreService.java +++ b/app/src/main/java/com/stevesoltys/backup/service/restore/RestoreService.java @@ -12,12 +12,9 @@ import com.stevesoltys.backup.activity.PopupWindowUtil; import com.stevesoltys.backup.activity.restore.RestorePopupWindowListener; import com.stevesoltys.backup.service.TransportService; import com.stevesoltys.backup.session.restore.RestoreSession; -import com.stevesoltys.backup.transport.ConfigurableBackupTransport; import java.util.Set; -import static com.stevesoltys.backup.transport.ConfigurableBackupTransportService.getBackupTransport; - /** * @author Steve Soltys */ @@ -28,8 +25,6 @@ public class RestoreService { private final TransportService transportService = new TransportService(); public void restorePackages(Set selectedPackages, Uri contentUri, Activity parent, String password) { - ConfigurableBackupTransport backupTransport = getBackupTransport(parent.getApplication()); - backupTransport.prepareRestore(password, contentUri); try { PopupWindow popupWindow = PopupWindowUtil.showLoadingPopupWindow(parent); RestoreObserver restoreObserver = new RestoreObserver(parent, popupWindow, selectedPackages.size()); diff --git a/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt index 140ea439..eab22e01 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/BackupLocationFragment.kt @@ -5,6 +5,7 @@ import android.content.ActivityNotFoundException import android.content.Intent import android.content.Intent.* import android.os.Bundle +import android.provider.DocumentsContract.EXTRA_PROMPT import android.widget.Toast import android.widget.Toast.LENGTH_LONG import androidx.lifecycle.ViewModelProviders @@ -38,6 +39,7 @@ class BackupLocationFragment : PreferenceFragmentCompat() { private fun showChooseFolderActivity() { val openTreeIntent = Intent(ACTION_OPEN_DOCUMENT_TREE) + openTreeIntent.putExtra(EXTRA_PROMPT, getString(R.string.settings_backup_location_picker)) openTreeIntent.addFlags(FLAG_GRANT_PERSISTABLE_URI_PERMISSION or FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION) try { diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt index a64f4ec8..59643546 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt @@ -3,9 +3,9 @@ package com.stevesoltys.backup.settings import android.app.Application import android.util.ByteStringUtils.toHexString import androidx.lifecycle.AndroidViewModel +import com.stevesoltys.backup.Backup import com.stevesoltys.backup.LiveEvent import com.stevesoltys.backup.MutableLiveEvent -import com.stevesoltys.backup.security.KeyManager import io.github.novacrypto.bip39.* import io.github.novacrypto.bip39.Validation.InvalidChecksumException import io.github.novacrypto.bip39.Validation.InvalidWordCountException @@ -48,7 +48,7 @@ class RecoveryCodeViewModel(application: Application) : AndroidViewModel(applica } val mnemonic = input.joinToString(" ") val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "") - KeyManager.storeBackupKey(seed) + Backup.keyManager.storeBackupKey(seed) // TODO remove once encryption/decryption uses key from KeyStore setBackupPassword(getApplication(), toHexString(seed)) diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt index ccc0bb35..e36627d7 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsFragment.kt @@ -10,6 +10,7 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.widget.Toast +import android.widget.Toast.LENGTH_SHORT import androidx.lifecycle.ViewModelProviders import androidx.preference.Preference.OnPreferenceChangeListener import androidx.preference.PreferenceFragmentCompat @@ -99,7 +100,7 @@ class SettingsFragment : PreferenceFragmentCompat() { true } item.itemId == R.id.action_restore -> { - Toast.makeText(requireContext(), "Not yet implemented", Toast.LENGTH_SHORT).show() + Toast.makeText(requireContext(), "Not yet implemented", LENGTH_SHORT).show() true } else -> super.onOptionsItemSelected(item) diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt index dd20a14a..4c243eba 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt @@ -4,13 +4,15 @@ import android.app.Application import android.content.Intent import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION +import android.util.Log import androidx.lifecycle.AndroidViewModel +import com.stevesoltys.backup.Backup import com.stevesoltys.backup.LiveEvent import com.stevesoltys.backup.MutableLiveEvent -import com.stevesoltys.backup.security.KeyManager import com.stevesoltys.backup.service.backup.requestFullBackup +import com.stevesoltys.backup.transport.ConfigurableBackupTransportService -private val TAG = SettingsViewModel::class.java.name +private val TAG = SettingsViewModel::class.java.simpleName class SettingsViewModel(application: Application) : AndroidViewModel(application) { @@ -27,7 +29,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application internal val chooseBackupLocation: LiveEvent = mChooseBackupLocation internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true) - fun recoveryCodeIsSet() = KeyManager.hasBackupKey() + fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey() fun locationIsSet() = getBackupFolderUri(getApplication()) != null fun handleChooseFolderResult(result: Intent?) { @@ -45,6 +47,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application // notify the UI that the location has been set locationWasSet.setEvent(wasEmptyBefore) + + // stop backup service to be sure the old location will get updated + app.stopService(Intent(app, ConfigurableBackupTransportService::class.java)) + + Log.d(TAG, "New storage location chosen: $folderUri") } fun backupNow() = Thread { requestFullBackup(app) }.start() diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java deleted file mode 100644 index 522a6a14..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.java +++ /dev/null @@ -1,199 +0,0 @@ -package com.stevesoltys.backup.transport; - -import android.app.backup.BackupTransport; -import android.app.backup.RestoreDescription; -import android.app.backup.RestoreSet; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageInfo; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.util.Log; - -import com.stevesoltys.backup.settings.SettingsActivity; -import com.stevesoltys.backup.transport.component.BackupComponent; -import com.stevesoltys.backup.transport.component.RestoreComponent; -import com.stevesoltys.backup.transport.component.provider.ContentProviderBackupComponent; -import com.stevesoltys.backup.transport.component.provider.ContentProviderRestoreComponent; - -import static android.app.backup.BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED; -import static android.os.Build.VERSION.SDK_INT; - -/** - * @author Steve Soltys - */ -public class ConfigurableBackupTransport extends BackupTransport { - - private static final String TRANSPORT_DIRECTORY_NAME = - "com.stevesoltys.backup.transport.ConfigurableBackupTransport"; - - private static final String TAG = TRANSPORT_DIRECTORY_NAME; - - private final Context context; - - private final BackupComponent backupComponent; - - private final RestoreComponent restoreComponent; - - ConfigurableBackupTransport(Context context) { - this.context = context; - backupComponent = new ContentProviderBackupComponent(context); - restoreComponent = new ContentProviderRestoreComponent(context); - } - - public void prepareRestore(String password, Uri fileUri) { - restoreComponent.prepareRestore(password, fileUri); - } - - @Override - public String transportDirName() { - return TRANSPORT_DIRECTORY_NAME; - } - - @Override - public String name() { - // TODO: Make this class non-static in ConfigurableBackupTransportService and use Context and a ComponentName. - return this.getClass().getName(); - } - - @Override - public int getTransportFlags() { - if (SDK_INT >= 28) return FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED; - return 0; - } - - @Override - public Intent dataManagementIntent() { - return new Intent(context, SettingsActivity.class); - } - - @Override - public boolean isAppEligibleForBackup(PackageInfo targetPackage, boolean isFullBackup) { - return true; - } - - @Override - public long requestBackupTime() { - return backupComponent.requestBackupTime(); - } - - @Override - public String dataManagementLabel() { - return backupComponent.dataManagementLabel(); - } - - @Override - public int initializeDevice() { - return backupComponent.initializeDevice(); - } - - @Override - public String currentDestinationString() { - return backupComponent.currentDestinationString(); - } - - /* Methods related to Backup */ - - @Override - public int performBackup(PackageInfo packageInfo, ParcelFileDescriptor inFd, int flags) { - return backupComponent.performIncrementalBackup(packageInfo, inFd, flags); - } - - @Override - public int performBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) { - Log.w(TAG, "Warning: Legacy performBackup() method called."); - return performBackup(targetPackage, fileDescriptor, 0); - } - - @Override - public int checkFullBackupSize(long size) { - return backupComponent.checkFullBackupSize(size); - } - - @Override - public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor socket, int flags) { - // TODO handle flags - return performFullBackup(targetPackage, socket); - } - - @Override - public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) { - return backupComponent.performFullBackup(targetPackage, fileDescriptor); - } - - @Override - public int sendBackupData(int numBytes) { - return backupComponent.sendBackupData(numBytes); - } - - @Override - public void cancelFullBackup() { - backupComponent.cancelFullBackup(); - } - - @Override - public int finishBackup() { - return backupComponent.finishBackup(); - } - - @Override - public long requestFullBackupTime() { - return backupComponent.requestFullBackupTime(); - } - - @Override - public long getBackupQuota(String packageName, boolean isFullBackup) { - return backupComponent.getBackupQuota(packageName, isFullBackup); - } - - @Override - public int clearBackupData(PackageInfo packageInfo) { - return backupComponent.clearBackupData(packageInfo); - } - - public void backupFinished() { - backupComponent.backupFinished(); - } - - /* Methods related to Restore */ - - @Override - public long getCurrentRestoreSet() { - return restoreComponent.getCurrentRestoreSet(); - } - - @Override - public int startRestore(long token, PackageInfo[] packages) { - return restoreComponent.startRestore(token, packages); - } - - @Override - public int getNextFullRestoreDataChunk(ParcelFileDescriptor socket) { - return restoreComponent.getNextFullRestoreDataChunk(socket); - } - - @Override - public RestoreSet[] getAvailableRestoreSets() { - return restoreComponent.getAvailableRestoreSets(); - } - - @Override - public RestoreDescription nextRestorePackage() { - return restoreComponent.nextRestorePackage(); - } - - @Override - public int getRestoreData(ParcelFileDescriptor outputFileDescriptor) { - return restoreComponent.getRestoreData(outputFileDescriptor); - } - - @Override - public int abortFullRestore() { - return restoreComponent.abortFullRestore(); - } - - @Override - public void finishRestore() { - restoreComponent.finishRestore(); - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt new file mode 100644 index 00000000..53e47cf0 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt @@ -0,0 +1,160 @@ +package com.stevesoltys.backup.transport + +import android.app.backup.BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED +import android.app.backup.BackupTransport +import android.app.backup.RestoreDescription +import android.app.backup.RestoreSet +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.os.Build.VERSION.SDK_INT +import android.os.ParcelFileDescriptor +import android.util.Log +import com.stevesoltys.backup.settings.SettingsActivity + +const val DEFAULT_RESTORE_SET_TOKEN: Long = 1 + +private const val TRANSPORT_DIRECTORY_NAME = "com.stevesoltys.backup.transport.ConfigurableBackupTransport" +private val TAG = ConfigurableBackupTransport::class.java.simpleName + +/** + * @author Steve Soltys + * @author Torsten Grote + */ +class ConfigurableBackupTransport internal constructor(private val context: Context) : BackupTransport() { + + private val pluginManager = PluginManager(context) + private val backupCoordinator = pluginManager.backupCoordinator + private val restoreCoordinator = pluginManager.restoreCoordinator + + override fun transportDirName(): String { + return TRANSPORT_DIRECTORY_NAME + } + + override fun name(): String { + // TODO: Make this class non-static in ConfigurableBackupTransportService and use Context and a ComponentName. + return this.javaClass.name + } + + override fun getTransportFlags(): Int { + return if (SDK_INT >= 28) FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED else 0 + } + + override fun dataManagementIntent(): Intent { + return Intent(context, SettingsActivity::class.java) + } + + override fun dataManagementLabel(): String { + return "Please file a bug if you see this! 1" + } + + override fun currentDestinationString(): String { + return "Please file a bug if you see this! 2" + } + + // ------------------------------------------------------------------------------------ + // General backup methods + // + + override fun initializeDevice(): Int { + return backupCoordinator.initializeDevice() + } + + override fun isAppEligibleForBackup(targetPackage: PackageInfo, isFullBackup: Boolean): Boolean { + return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup) + } + + override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { + return backupCoordinator.getBackupQuota(packageName, isFullBackup) + } + + override fun clearBackupData(packageInfo: PackageInfo): Int { + return backupCoordinator.clearBackupData(packageInfo) + } + + override fun finishBackup(): Int { + return backupCoordinator.finishBackup() + } + + // ------------------------------------------------------------------------------------ + // Key/value incremental backup support + // + + override fun requestBackupTime(): Long { + return backupCoordinator.requestBackupTime() + } + + override fun performBackup(packageInfo: PackageInfo, inFd: ParcelFileDescriptor, flags: Int): Int { + return backupCoordinator.performIncrementalBackup(packageInfo, inFd, flags) + } + + override fun performBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int { + Log.w(TAG, "Warning: Legacy performBackup() method called.") + return performBackup(targetPackage, fileDescriptor, 0) + } + + // ------------------------------------------------------------------------------------ + // Full backup + // + + override fun requestFullBackupTime(): Long { + return backupCoordinator.requestFullBackupTime() + } + + override fun checkFullBackupSize(size: Long): Int { + return backupCoordinator.checkFullBackupSize(size) + } + + override fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, flags: Int): Int { + return backupCoordinator.performFullBackup(targetPackage, socket, flags) + } + + override fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int { + return backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0) + } + + override fun sendBackupData(numBytes: Int): Int { + return backupCoordinator.sendBackupData(numBytes) + } + + override fun cancelFullBackup() { + backupCoordinator.cancelFullBackup() + } + + // ------------------------------------------------------------------------------------ + // Restore + // + + override fun getAvailableRestoreSets(): Array? { + return restoreCoordinator.getAvailableRestoreSets() + } + + override fun getCurrentRestoreSet(): Long { + return restoreCoordinator.getCurrentRestoreSet() + } + + override fun startRestore(token: Long, packages: Array): Int { + return restoreCoordinator.startRestore(token, packages) + } + + override fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int { + return restoreCoordinator.getNextFullRestoreDataChunk(socket) + } + + override fun nextRestorePackage(): RestoreDescription? { + return restoreCoordinator.nextRestorePackage() + } + + override fun getRestoreData(outputFileDescriptor: ParcelFileDescriptor): Int { + return restoreCoordinator.getRestoreData(outputFileDescriptor) + } + + override fun abortFullRestore(): Int { + return restoreCoordinator.abortFullRestore() + } + + override fun finishRestore() { + restoreCoordinator.finishRestore() + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.java b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.java deleted file mode 100644 index da896889..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.stevesoltys.backup.transport; - -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.IBinder; -import android.util.Log; - -/** - * @author Steve Soltys - */ -public class ConfigurableBackupTransportService extends Service { - - private static final String TAG = ConfigurableBackupTransportService.class.getName(); - - private static ConfigurableBackupTransport backupTransport = null; - - public static ConfigurableBackupTransport getBackupTransport(Context context) { - - if (backupTransport == null) { - backupTransport = new ConfigurableBackupTransport(context); - } - - return backupTransport; - } - - @Override - public void onCreate() { - super.onCreate(); - Log.d(TAG, "Service created."); - } - - @Override - public IBinder onBind(Intent intent) { - return getBackupTransport(getApplicationContext()).getBinder(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - Log.d(TAG, "Service destroyed."); - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt new file mode 100644 index 00000000..f178245d --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt @@ -0,0 +1,37 @@ +package com.stevesoltys.backup.transport + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.util.Log + +private val TAG = ConfigurableBackupTransportService::class.java.simpleName + +/** + * @author Steve Soltys + * @author Torsten Grote + */ +class ConfigurableBackupTransportService : Service() { + + private var transport: ConfigurableBackupTransport? = null + + override fun onCreate() { + super.onCreate() + transport = ConfigurableBackupTransport(applicationContext) + Log.d(TAG, "Service created.") + } + + override fun onBind(intent: Intent): IBinder { + val transport = this.transport ?: throw IllegalStateException() + return transport.binder.apply { + Log.d(TAG, "Transport bound.") + } + } + + override fun onDestroy() { + super.onDestroy() + transport = null + Log.d(TAG, "Service destroyed.") + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt new file mode 100644 index 00000000..0c0c6471 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt @@ -0,0 +1,56 @@ +package com.stevesoltys.backup.transport + +import android.content.Context +import android.os.Build +import com.stevesoltys.backup.Backup +import com.stevesoltys.backup.crypto.CipherFactoryImpl +import com.stevesoltys.backup.crypto.CryptoImpl +import com.stevesoltys.backup.header.HeaderReaderImpl +import com.stevesoltys.backup.header.HeaderWriterImpl +import com.stevesoltys.backup.settings.getBackupFolderUri +import com.stevesoltys.backup.transport.backup.BackupCoordinator +import com.stevesoltys.backup.transport.backup.FullBackup +import com.stevesoltys.backup.transport.backup.InputFactory +import com.stevesoltys.backup.transport.backup.KVBackup +import com.stevesoltys.backup.transport.backup.plugins.DocumentsProviderBackupPlugin +import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage +import com.stevesoltys.backup.transport.restore.FullRestore +import com.stevesoltys.backup.transport.restore.KVRestore +import com.stevesoltys.backup.transport.restore.OutputFactory +import com.stevesoltys.backup.transport.restore.RestoreCoordinator +import com.stevesoltys.backup.transport.restore.plugins.DocumentsProviderRestorePlugin + +class PluginManager(context: Context) { + + // We can think about using an injection framework such as Dagger to simplify this. + + private val storage = DocumentsStorage(context, getBackupFolderUri(context), getDeviceName()) + + private val headerWriter = HeaderWriterImpl() + private val headerReader = HeaderReaderImpl() + private val cipherFactory = CipherFactoryImpl(Backup.keyManager) + private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader) + + + private val backupPlugin = DocumentsProviderBackupPlugin(storage, context.packageManager) + private val inputFactory = InputFactory() + private val kvBackup = KVBackup(backupPlugin.kvBackupPlugin, inputFactory, headerWriter, crypto) + private val fullBackup = FullBackup(backupPlugin.fullBackupPlugin, inputFactory, headerWriter, crypto) + + internal val backupCoordinator = BackupCoordinator(backupPlugin, kvBackup, fullBackup) + + + private val restorePlugin = DocumentsProviderRestorePlugin(storage) + private val outputFactory = OutputFactory() + private val kvRestore = KVRestore(restorePlugin.kvRestorePlugin, outputFactory, headerReader, crypto) + private val fullRestore = FullRestore(restorePlugin.fullRestorePlugin, outputFactory, headerReader, crypto) + + internal val restoreCoordinator = RestoreCoordinator(restorePlugin, kvRestore, fullRestore) + + + private fun getDeviceName(): String { + // TODO add device specific unique ID to the end + return "${Build.MANUFACTURER} ${Build.MODEL}" + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt new file mode 100644 index 00000000..65ad91b0 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt @@ -0,0 +1,144 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupTransport.TRANSPORT_ERROR +import android.app.backup.BackupTransport.TRANSPORT_OK +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import android.util.Log +import java.io.IOException + +private val TAG = BackupCoordinator::class.java.simpleName + +/** + * @author Steve Soltys + * @author Torsten Grote + */ +class BackupCoordinator( + private val plugin: BackupPlugin, + private val kv: KVBackup, + private val full: FullBackup) { + + private var calledInitialize = false + private var calledClearBackupData = false + + // ------------------------------------------------------------------------------------ + // Transport initialization and quota + // + + /** + * Initialize the storage for this device, erasing all stored data. + * The transport may send the request immediately, or may buffer it. + * After this is called, + * [finishBackup] will be called to ensure the request is sent and received successfully. + * + * If the transport returns anything other than [TRANSPORT_OK] from this method, + * the OS will halt the current initialize operation and schedule a retry in the near future. + * Even if the transport is in a state + * such that attempting to "initialize" the backend storage is meaningless - + * for example, if there is no current live data-set at all, + * or there is no authenticated account under which to store the data remotely - + * the transport should return [TRANSPORT_OK] here + * and treat the initializeDevice() / finishBackup() pair as a graceful no-op. + * + * @return One of [TRANSPORT_OK] (OK so far) or + * [TRANSPORT_ERROR] (to retry following network error or other failure). + */ + fun initializeDevice(): Int { + Log.i(TAG, "Initialize Device!") + return try { + plugin.initializeDevice() + // [finishBackup] will only be called when we return [TRANSPORT_OK] here + // so we remember that we initialized successfully + calledInitialize = true + TRANSPORT_OK + } catch (e: IOException) { + Log.e(TAG, "Error initializing device", e) + TRANSPORT_ERROR + } + } + + fun isAppEligibleForBackup(targetPackage: PackageInfo, @Suppress("UNUSED_PARAMETER") isFullBackup: Boolean): Boolean { + // We need to exclude the DocumentsProvider used to store backup data. + // Otherwise, it gets killed when we back it up, terminating our backup. + return targetPackage.packageName != plugin.providerPackageName + } + + fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { + Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.") + val quota = if (isFullBackup) full.getQuota() else kv.getQuota() + Log.i(TAG, "Reported quota of $quota bytes.") + return quota + } + + // ------------------------------------------------------------------------------------ + // Key/value incremental backup support + // + + fun requestBackupTime() = kv.requestBackupTime() + + fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int) = + kv.performBackup(packageInfo, data, flags) + + // ------------------------------------------------------------------------------------ + // Full backup + // + + fun requestFullBackupTime() = full.requestFullBackupTime() + + fun checkFullBackupSize(size: Long) = full.checkFullBackupSize(size) + + fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int) = + full.performFullBackup(targetPackage, fileDescriptor, flags) + + fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes) + + fun cancelFullBackup() = full.cancelFullBackup() + + // Clear and Finish + + /** + * Erase the given application's data from the backup destination. + * This clears out the given package's data from the current backup set, + * making it as though the app had never yet been backed up. + * After this is called, [finishBackup] must be called + * to ensure that the operation is recorded successfully. + * + * @return the same error codes as [performFullBackup]. + */ + fun clearBackupData(packageInfo: PackageInfo): Int { + val packageName = packageInfo.packageName + Log.i(TAG, "Clear Backup Data of $packageName.") + try { + kv.clearBackupData(packageInfo) + } catch (e: IOException) { + Log.w(TAG, "Error clearing K/V backup data for $packageName", e) + return TRANSPORT_ERROR + } + try { + full.clearBackupData(packageInfo) + } catch (e: IOException) { + Log.w(TAG, "Error clearing full backup data for $packageName", e) + return TRANSPORT_ERROR + } + calledClearBackupData = true + return TRANSPORT_OK + } + + fun finishBackup(): Int = when { + kv.hasState() -> { + if (full.hasState()) throw IllegalStateException() + kv.finishBackup() + } + full.hasState() -> { + if (kv.hasState()) throw IllegalStateException() + full.finishBackup() + } + calledInitialize || calledClearBackupData -> { + calledInitialize = false + calledClearBackupData = false + TRANSPORT_OK + } + else -> throw IllegalStateException() + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt new file mode 100644 index 00000000..b3d6f5dc --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupPlugin.kt @@ -0,0 +1,27 @@ +package com.stevesoltys.backup.transport.backup + +import java.io.IOException + +interface BackupPlugin { + + val kvBackupPlugin: KVBackupPlugin + + val fullBackupPlugin: FullBackupPlugin + + /** + * Initialize the storage for this device, erasing all stored data. + */ + @Throws(IOException::class) + fun initializeDevice() + + /** + * Returns the package name of the app that provides the backend storage + * which is used for the current backup location. + * + * Plugins are advised to cache this as it will be requested frequently. + * + * @return null if no package name could be found + */ + val providerPackageName: String? + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt new file mode 100644 index 00000000..0a56c59b --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt @@ -0,0 +1,191 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupTransport.* +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import android.util.Log +import com.stevesoltys.backup.crypto.Crypto +import com.stevesoltys.backup.header.HeaderWriter +import com.stevesoltys.backup.header.VersionHeader +import libcore.io.IoUtils.closeQuietly +import org.apache.commons.io.IOUtils +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +private class FullBackupState( + internal val packageInfo: PackageInfo, + internal val inputFileDescriptor: ParcelFileDescriptor, + internal val inputStream: InputStream, + internal val outputStream: OutputStream) { + internal val packageName: String = packageInfo.packageName + internal var size: Long = 0 +} + +const val DEFAULT_QUOTA_FULL_BACKUP = (2 * (25 * 1024 * 1024)).toLong() + +private val TAG = FullBackup::class.java.simpleName + +class FullBackup( + private val plugin: FullBackupPlugin, + private val inputFactory: InputFactory, + private val headerWriter: HeaderWriter, + private val crypto: Crypto) { + + private var state: FullBackupState? = null + + fun hasState() = state != null + + fun requestFullBackupTime(): Long { + Log.i(TAG, "Request full backup time") + return 0 + } + + fun getQuota(): Long = plugin.getQuota() + + fun checkFullBackupSize(size: Long): Int { + Log.i(TAG, "Check full backup size of $size bytes.") + return when { + size <= 0 -> TRANSPORT_PACKAGE_REJECTED + size > plugin.getQuota() -> TRANSPORT_QUOTA_EXCEEDED + else -> TRANSPORT_OK + } + } + + /** + * Begin the process of sending a packages' full-data archive to the backend. + * The description of the package whose data will be delivered is provided, + * as well as the socket file descriptor on which the transport will receive the data itself. + * + * If the package is not eligible for backup, + * the transport should return [TRANSPORT_PACKAGE_REJECTED]. + * In this case the system will simply proceed with the next candidate if any, + * or finish the full backup operation if all apps have been processed. + * + * After the transport returns [TRANSPORT_OK] from this method, + * the OS will proceed to call [sendBackupData] one or more times + * to deliver the packages' data as a streamed tarball. + * The transport should not read() from the socket except as instructed to + * via the [sendBackupData] method. + * + * After all data has been delivered to the transport, the system will call [finishBackup]. + * At this point the transport should commit the data to its datastore, if appropriate, + * and close the socket that had been provided in [performFullBackup]. + * + * If the transport returns [TRANSPORT_OK] from this method, + * then the OS will always provide a matching call to [finishBackup] + * even if sending data via [sendBackupData] failed at some point. + * + * @param targetPackage The package whose data is to follow. + * @param socket The socket file descriptor through which the data will be provided. + * If the transport returns [TRANSPORT_PACKAGE_REJECTED] here, + * it must still close this file descriptor now; + * otherwise it should be cached for use during succeeding calls to [sendBackupData], + * and closed in response to [finishBackup]. + * @param flags [FLAG_USER_INITIATED] or 0. + * @return [TRANSPORT_PACKAGE_REJECTED] to indicate that the package is not to be backed up; + * [TRANSPORT_OK] to indicate that the OS may proceed with delivering backup data; + * [TRANSPORT_ERROR] to indicate an error that precludes performing a backup at this time. + */ + fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, @Suppress("UNUSED_PARAMETER") flags: Int = 0): Int { + if (state != null) throw AssertionError() + Log.i(TAG, "Perform full backup for ${targetPackage.packageName}.") + + // get OutputStream to write backup data into + val outputStream = try { + plugin.getOutputStream(targetPackage) + } catch (e: IOException) { + Log.e(TAG, "Error getting OutputStream for full backup of ${targetPackage.packageName}", e) + return backupError(TRANSPORT_ERROR) + } + + // create new state + val inputStream = inputFactory.getInputStream(socket) + state = FullBackupState(targetPackage, socket, inputStream, outputStream) + + // store version header + val state = this.state ?: throw AssertionError() + val header = VersionHeader(packageName = state.packageName) + try { + headerWriter.writeVersion(state.outputStream, header) + crypto.encryptHeader(state.outputStream, header) + } catch (e: IOException) { + Log.e(TAG, "Error writing backup header", e) + return backupError(TRANSPORT_ERROR) + } + return TRANSPORT_OK + } + + /** + * Method to reset state, + * because [finishBackup] is not called + * when we don't return [TRANSPORT_OK] from [performFullBackup]. + */ + private fun backupError(result: Int): Int { + Log.i(TAG, "Resetting state because of full backup error.") + state = null + return result + } + + fun sendBackupData(numBytes: Int): Int { + val state = this.state + ?: throw AssertionError("Attempted sendBackupData before performFullBackup") + + // check if size fits quota + state.size += numBytes + val quota = plugin.getQuota() + if (state.size > quota) { + Log.w(TAG, "Full backup of additional $numBytes exceeds quota of $quota with ${state.size}.") + return TRANSPORT_QUOTA_EXCEEDED + } + + Log.i(TAG, "Send full backup data of $numBytes bytes.") + + return try { + val payload = IOUtils.readFully(state.inputStream, numBytes) + crypto.encryptSegment(state.outputStream, payload) + TRANSPORT_OK + } catch (e: IOException) { + Log.e(TAG, "Error handling backup data for ${state.packageName}: ", e) + TRANSPORT_ERROR + } + } + + fun clearBackupData(packageInfo: PackageInfo) { + // TODO + } + + fun cancelFullBackup() { + Log.i(TAG, "Cancel full backup") + val state = this.state ?: throw AssertionError("No state when canceling") + clearState() + try { + plugin.cancelFullBackup(state.packageInfo) + } catch (e: IOException) { + Log.w(TAG, "Error cancelling full backup for ${state.packageName}", e) + } + // TODO roll back to the previous known-good archive + } + + fun finishBackup(): Int { + Log.i(TAG, "Finish full backup of ${state!!.packageName}.") + return clearState() + } + + private fun clearState(): Int { + val state = this.state ?: throw AssertionError("Trying to clear empty state.") + return try { + state.outputStream.flush() + closeQuietly(state.outputStream) + closeQuietly(state.inputStream) + closeQuietly(state.inputFileDescriptor) + TRANSPORT_OK + } catch (e: IOException) { + Log.w(TAG, "Error when clearing state", e) + TRANSPORT_ERROR + } finally { + this.state = null + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt new file mode 100644 index 00000000..e1c882d6 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt @@ -0,0 +1,17 @@ +package com.stevesoltys.backup.transport.backup + +import android.content.pm.PackageInfo +import java.io.IOException +import java.io.OutputStream + +interface FullBackupPlugin { + + fun getQuota(): Long + + @Throws(IOException::class) + fun getOutputStream(targetPackage: PackageInfo): OutputStream + + @Throws(IOException::class) + fun cancelFullBackup(targetPackage: PackageInfo) + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/InputFactory.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/InputFactory.kt new file mode 100644 index 00000000..78db8dbb --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/InputFactory.kt @@ -0,0 +1,21 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupDataInput +import android.os.ParcelFileDescriptor +import java.io.FileInputStream +import java.io.InputStream + +/** + * This class exists for easier testing, so we can mock it and return custom data inputs. + */ +class InputFactory { + + fun getBackupDataInput(inputFileDescriptor: ParcelFileDescriptor): BackupDataInput { + return BackupDataInput(inputFileDescriptor.fileDescriptor) + } + + fun getInputStream(inputFileDescriptor: ParcelFileDescriptor): InputStream { + return FileInputStream(inputFileDescriptor.fileDescriptor) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt new file mode 100644 index 00000000..5a14f43a --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackup.kt @@ -0,0 +1,200 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupTransport.* +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import android.util.Log +import com.stevesoltys.backup.crypto.Crypto +import com.stevesoltys.backup.header.HeaderWriter +import com.stevesoltys.backup.header.Utf8 +import com.stevesoltys.backup.header.VersionHeader +import libcore.io.IoUtils.closeQuietly +import java.io.IOException +import java.util.Base64.getUrlEncoder + +class KVBackupState(internal val packageName: String) + +const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong() + +private val TAG = KVBackup::class.java.simpleName + +class KVBackup( + private val plugin: KVBackupPlugin, + private val inputFactory: InputFactory, + private val headerWriter: HeaderWriter, + private val crypto: Crypto) { + + private var state: KVBackupState? = null + + fun hasState() = state != null + + fun requestBackupTime(): Long { + Log.i(TAG, "Request K/V backup time") + return 0 + } + + fun getQuota(): Long = plugin.getQuota() + + fun performBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int { + val isIncremental = flags and FLAG_INCREMENTAL != 0 + val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0 + val packageName = packageInfo.packageName + + when { + isIncremental -> { + Log.i(TAG, "Performing incremental K/V backup for $packageName") + } + isNonIncremental -> { + Log.i(TAG, "Performing non-incremental K/V backup for $packageName") + } + else -> { + Log.i(TAG, "Performing K/V backup for $packageName") + } + } + + // initialize state + if (this.state != null) throw AssertionError() + this.state = KVBackupState(packageInfo.packageName) + + // check if we have existing data for the given package + val hasDataForPackage = try { + plugin.hasDataForPackage(packageInfo) + } catch (e: IOException) { + Log.e(TAG, "Error checking for existing data for ${packageInfo.packageName}.", e) + return backupError(TRANSPORT_ERROR) + } + if (isIncremental && !hasDataForPackage) { + Log.w(TAG, "Requested incremental, but transport currently stores no data $packageName, requesting non-incremental retry.") + return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED) + } + + // TODO check if package is over-quota + + if (isNonIncremental && hasDataForPackage) { + Log.w(TAG, "Requested non-incremental, deleting existing data.") + try { + clearBackupData(packageInfo) + } catch (e: IOException) { + Log.w(TAG, "Error clearing backup data for ${packageInfo.packageName}.", e) + } + } + + // ensure there's a place to store K/V for the given package + try { + plugin.ensureRecordStorageForPackage(packageInfo) + } catch (e: IOException) { + Log.e(TAG, "Error ensuring storage for ${packageInfo.packageName}.", e) + return backupError(TRANSPORT_ERROR) + } + + // parse and store the K/V updates + return storeRecords(packageInfo, data) + } + + private fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int { + // apply the delta operations + for (result in parseBackupStream(data)) { + if (result is Result.Error) { + Log.e(TAG, "Exception reading backup input", result.exception) + return backupError(TRANSPORT_ERROR) + } + val op = (result as Result.Ok).result + try { + if (op.value == null) { + Log.e(TAG, "Deleting record with base64Key ${op.base64Key}") + plugin.deleteRecord(packageInfo, op.base64Key) + } else { + val outputStream = plugin.getOutputStreamForRecord(packageInfo, op.base64Key) + val header = VersionHeader(packageName = packageInfo.packageName, key = op.key) + headerWriter.writeVersion(outputStream, header) + crypto.encryptHeader(outputStream, header) + crypto.encryptSegment(outputStream, op.value) + outputStream.flush() + closeQuietly(outputStream) + } + } catch (e: IOException) { + Log.e(TAG, "Unable to update base64Key file for base64Key ${op.base64Key}", e) + return backupError(TRANSPORT_ERROR) + } + } + return TRANSPORT_OK + } + + /** + * Parses a backup stream into individual key/value operations + */ + private fun parseBackupStream(data: ParcelFileDescriptor): Sequence> { + val changeSet = inputFactory.getBackupDataInput(data) + + // Each K/V pair in the restore set is kept in its own file, named by the record key. + // Wind through the data file, extracting individual record operations + // and building a sequence of all the updates to apply in this update. + return generateSequence { + // read the next header or end the sequence in case of error or no more headers + try { + if (!changeSet.readNextHeader()) return@generateSequence null // end the sequence + } catch (e: IOException) { + Log.e(TAG, "Error reading next header", e) + return@generateSequence Result.Error(e) + } + // encode key + val key = changeSet.key + val base64Key = getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8)) + val dataSize = changeSet.dataSize + + // read and encrypt value + val value = if (dataSize >= 0) { + Log.v(TAG, " Delta operation key $key size $dataSize key64 $base64Key") + val bytes = ByteArray(dataSize) + val bytesRead = try { + changeSet.readEntityData(bytes, 0, dataSize) + } catch (e: IOException) { + Log.e(TAG, "Error reading entity data for key $key", e) + return@generateSequence Result.Error(e) + } + if (bytesRead != dataSize) { + Log.w(TAG, "Expecting $dataSize bytes, but only read $bytesRead.") + } + bytes + } else null + // add change operation to the sequence + Result.Ok(KVOperation(key, base64Key, value)) + } + } + + @Throws(IOException::class) + fun clearBackupData(packageInfo: PackageInfo) { + plugin.removeDataOfPackage(packageInfo) + } + + fun finishBackup(): Int { + Log.i(TAG, "Finish K/V Backup of ${state!!.packageName}") + state = null + return TRANSPORT_OK + } + + /** + * Method to reset state, + * because [finishBackup] is not called when we don't return [TRANSPORT_OK]. + */ + private fun backupError(result: Int): Int { + Log.i(TAG, "Resetting state because of K/V Backup error of ${state!!.packageName}") + state = null + return result + } + + private class KVOperation( + internal val key: String, + internal val base64Key: String, + /** + * value is null when this is a deletion operation + */ + internal val value: ByteArray? + ) + + private sealed class Result { + class Ok(val result: T) : Result() + class Error(val exception: Exception) : Result() + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackupPlugin.kt new file mode 100644 index 00000000..4e1146ee --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/KVBackupPlugin.kt @@ -0,0 +1,48 @@ +package com.stevesoltys.backup.transport.backup + +import android.content.pm.PackageInfo +import java.io.IOException +import java.io.OutputStream + +interface KVBackupPlugin { + + /** + * Get quota for key/value backups. + */ + fun getQuota(): Long + + /** + * Return true if there are records stored for the given package. + */ + @Throws(IOException::class) + fun hasDataForPackage(packageInfo: PackageInfo): Boolean + + /** + * This marks the beginning of a backup operation. + * + * Make sure that there is a place to store K/V pairs for the given package. + * E.g. file-based plugins should a create a directory for the package, if none exists. + */ + @Throws(IOException::class) + fun ensureRecordStorageForPackage(packageInfo: PackageInfo) + + /** + * Return an [OutputStream] for the given package and key + * which will receive the record's encrypted value. + */ + @Throws(IOException::class) + fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream + + /** + * Delete the record for the given package identified by the given key. + */ + @Throws(IOException::class) + fun deleteRecord(packageInfo: PackageInfo, key: String) + + /** + * Remove all data associated with the given package. + */ + @Throws(IOException::class) + fun removeDataOfPackage(packageInfo: PackageInfo) + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt new file mode 100644 index 00000000..2d80d581 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt @@ -0,0 +1,46 @@ +package com.stevesoltys.backup.transport.backup.plugins + +import android.content.pm.PackageManager +import com.stevesoltys.backup.transport.backup.BackupPlugin +import com.stevesoltys.backup.transport.backup.FullBackupPlugin +import com.stevesoltys.backup.transport.backup.KVBackupPlugin +import java.io.IOException + +private const val NO_MEDIA = ".nomedia" + +class DocumentsProviderBackupPlugin( + private val storage: DocumentsStorage, + packageManager: PackageManager) : BackupPlugin { + + override val kvBackupPlugin: KVBackupPlugin by lazy { + DocumentsProviderKVBackup(storage) + } + + override val fullBackupPlugin: FullBackupPlugin by lazy { + DocumentsProviderFullBackup(storage) + } + + @Throws(IOException::class) + override fun initializeDevice() { + // get or create root backup dir + val rootDir = storage.rootBackupDir ?: throw IOException() + + // create .nomedia file to prevent Android's MediaScanner from trying to index the backup + rootDir.createOrGetFile(NO_MEDIA) + + // create backup folders + val kvDir = storage.defaultKvBackupDir + val fullDir = storage.defaultFullBackupDir + + // wipe existing data + kvDir?.deleteContents() + fullDir?.deleteContents() + } + + override val providerPackageName: String? by lazy { + val authority = storage.rootBackupDir?.uri?.authority ?: return@lazy null + val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null + providerInfo.packageName + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt new file mode 100644 index 00000000..2116b926 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt @@ -0,0 +1,33 @@ +package com.stevesoltys.backup.transport.backup.plugins + +import android.content.pm.PackageInfo +import android.util.Log +import com.stevesoltys.backup.transport.backup.DEFAULT_QUOTA_FULL_BACKUP +import com.stevesoltys.backup.transport.backup.FullBackupPlugin +import java.io.IOException +import java.io.OutputStream + +private val TAG = DocumentsProviderFullBackup::class.java.simpleName + +class DocumentsProviderFullBackup( + private val storage: DocumentsStorage) : FullBackupPlugin { + + override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP + + @Throws(IOException::class) + override fun getOutputStream(targetPackage: PackageInfo): OutputStream { + // TODO test file-size after overwriting bigger file + val file = storage.defaultFullBackupDir?.createOrGetFile(targetPackage.packageName) + ?: throw IOException() + return storage.getOutputStream(file) + } + + @Throws(IOException::class) + override fun cancelFullBackup(targetPackage: PackageInfo) { + val packageName = targetPackage.packageName + Log.i(TAG, "Deleting $packageName due to canceled backup...") + val file = storage.defaultFullBackupDir?.findFile(packageName) ?: return + if (!file.delete()) throw IOException("Failed to delete $packageName") + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt new file mode 100644 index 00000000..dd6a08ca --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt @@ -0,0 +1,54 @@ +package com.stevesoltys.backup.transport.backup.plugins + +import android.content.pm.PackageInfo +import androidx.documentfile.provider.DocumentFile +import com.stevesoltys.backup.transport.backup.DEFAULT_QUOTA_KEY_VALUE_BACKUP +import com.stevesoltys.backup.transport.backup.KVBackupPlugin +import java.io.IOException +import java.io.OutputStream + +class DocumentsProviderKVBackup(private val storage: DocumentsStorage) : KVBackupPlugin { + + private var packageFile: DocumentFile? = null + + override fun getQuota(): Long = DEFAULT_QUOTA_KEY_VALUE_BACKUP + + @Throws(IOException::class) + override fun hasDataForPackage(packageInfo: PackageInfo): Boolean { + val packageFile = storage.defaultKvBackupDir?.findFile(packageInfo.packageName) + ?: return false + return packageFile.listFiles().isNotEmpty() + } + + @Throws(IOException::class) + override fun ensureRecordStorageForPackage(packageInfo: PackageInfo) { + // remember package file for subsequent operations + packageFile = storage.getOrCreateKVBackupDir().createOrGetDirectory(packageInfo.packageName) + } + + @Throws(IOException::class) + override fun removeDataOfPackage(packageInfo: PackageInfo) { + // we cannot use the cached this.packageFile here, + // because this can be called before [ensureRecordStorageForPackage] + val packageFile = storage.defaultKvBackupDir?.findFile(packageInfo.packageName) ?: return + packageFile.delete() + } + + @Throws(IOException::class) + override fun deleteRecord(packageInfo: PackageInfo, key: String) { + val packageFile = this.packageFile ?: throw AssertionError() + packageFile.assertRightFile(packageInfo) + val keyFile = packageFile.findFile(key) ?: return + keyFile.delete() + } + + @Throws(IOException::class) + override fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream { + val packageFile = this.packageFile ?: throw AssertionError() + packageFile.assertRightFile(packageInfo) + // TODO check what happens if we overwrite a bigger file + val keyFile = packageFile.createOrGetFile(key) + return storage.getOutputStream(keyFile) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt new file mode 100644 index 00000000..20bf10df --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsStorage.kt @@ -0,0 +1,123 @@ +package com.stevesoltys.backup.transport.backup.plugins + +import android.content.Context +import android.content.pm.PackageInfo +import android.net.Uri +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +const val DIRECTORY_FULL_BACKUP = "full" +const val DIRECTORY_KEY_VALUE_BACKUP = "kv" +private const val ROOT_DIR_NAME = ".AndroidBackup" +private const val MIME_TYPE = "application/octet-stream" + +private val TAG = DocumentsStorage::class.java.simpleName + +class DocumentsStorage(context: Context, parentFolder: Uri?, deviceName: String) { + + private val contentResolver = context.contentResolver + + internal val rootBackupDir: DocumentFile? by lazy { + val folderUri = parentFolder ?: return@lazy null + val parent = DocumentFile.fromTreeUri(context, folderUri) ?: throw AssertionError() + try { + parent.createOrGetDirectory(ROOT_DIR_NAME) + } catch (e: IOException) { + Log.e(TAG, "Error creating root backup dir.", e) + null + } + } + + private val deviceDir: DocumentFile? by lazy { + try { + rootBackupDir?.createOrGetDirectory(deviceName) + } catch (e: IOException) { + Log.e(TAG, "Error creating current restore set dir.", e) + null + } + } + + private val defaultSetDir: DocumentFile? by lazy { + val currentSetName = DEFAULT_RESTORE_SET_TOKEN.toString() + try { + deviceDir?.createOrGetDirectory(currentSetName) + } catch (e: IOException) { + Log.e(TAG, "Error creating current restore set dir.", e) + null + } + } + + val defaultFullBackupDir: DocumentFile? by lazy { + try { + defaultSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP) + } catch (e: IOException) { + Log.e(TAG, "Error creating full backup dir.", e) + null + } + } + + val defaultKvBackupDir: DocumentFile? by lazy { + try { + defaultSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP) + } catch (e: IOException) { + Log.e(TAG, "Error creating K/V backup dir.", e) + null + } + } + + private fun getSetDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? { + if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultSetDir + return deviceDir?.findFile(token.toString()) + } + + fun getKVBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? { + if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultKvBackupDir ?: throw IOException() + return getSetDir(token)?.findFile(DIRECTORY_KEY_VALUE_BACKUP) + } + + @Throws(IOException::class) + fun getOrCreateKVBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile { + if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultKvBackupDir ?: throw IOException() + val setDir = getSetDir(token) ?: throw IOException() + return setDir.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP) + } + + fun getFullBackupDir(token: Long = DEFAULT_RESTORE_SET_TOKEN): DocumentFile? { + if (token == DEFAULT_RESTORE_SET_TOKEN) return defaultFullBackupDir ?: throw IOException() + return getSetDir(token)?.findFile(DIRECTORY_FULL_BACKUP) + } + + @Throws(IOException::class) + fun getInputStream(file: DocumentFile): InputStream { + return contentResolver.openInputStream(file.uri) ?: throw IOException() + } + + @Throws(IOException::class) + fun getOutputStream(file: DocumentFile): OutputStream { + return contentResolver.openOutputStream(file.uri) ?: throw IOException() + } + +} + +@Throws(IOException::class) +fun DocumentFile.createOrGetFile(name: String, mimeType: String = MIME_TYPE): DocumentFile { + return findFile(name) ?: createFile(mimeType, name) ?: throw IOException() +} + +@Throws(IOException::class) +fun DocumentFile.createOrGetDirectory(name: String): DocumentFile { + return findFile(name) ?: createDirectory(name) ?: throw IOException() +} + +@Throws(IOException::class) +fun DocumentFile.deleteContents() { + for (file in listFiles()) file.delete() +} + +fun DocumentFile.assertRightFile(packageInfo: PackageInfo) { + if (name != packageInfo.packageName) throw AssertionError() +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/BackupComponent.java b/app/src/main/java/com/stevesoltys/backup/transport/component/BackupComponent.java deleted file mode 100644 index 92715d61..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/BackupComponent.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.stevesoltys.backup.transport.component; - -import android.content.pm.PackageInfo; -import android.os.ParcelFileDescriptor; - -/** - * @author Steve Soltys - */ -public interface BackupComponent { - - String currentDestinationString(); - - String dataManagementLabel(); - - int initializeDevice(); - - int clearBackupData(PackageInfo packageInfo); - - int finishBackup(); - - int performIncrementalBackup(PackageInfo targetPackage, ParcelFileDescriptor data, int flags); - - int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor); - - int checkFullBackupSize(long size); - - int sendBackupData(int numBytes); - - void cancelFullBackup(); - - long getBackupQuota(String packageName, boolean fullBackup); - - long requestBackupTime(); - - long requestFullBackupTime(); - - void backupFinished(); -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/RestoreComponent.java b/app/src/main/java/com/stevesoltys/backup/transport/component/RestoreComponent.java deleted file mode 100644 index 08f866a0..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/RestoreComponent.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.stevesoltys.backup.transport.component; - -import android.app.backup.RestoreDescription; -import android.app.backup.RestoreSet; -import android.content.pm.PackageInfo; -import android.net.Uri; -import android.os.ParcelFileDescriptor; - -/** - * @author Steve Soltys - */ -public interface RestoreComponent { - - void prepareRestore(String password, Uri fileUri); - - int startRestore(long token, PackageInfo[] packages); - - RestoreDescription nextRestorePackage(); - - int getRestoreData(ParcelFileDescriptor outputFileDescriptor); - - int getNextFullRestoreDataChunk(ParcelFileDescriptor socket); - - int abortFullRestore(); - - long getCurrentRestoreSet(); - - void finishRestore(); - - RestoreSet[] getAvailableRestoreSets(); -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java deleted file mode 100644 index a951d50a..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupComponent.java +++ /dev/null @@ -1,367 +0,0 @@ -package com.stevesoltys.backup.transport.component.provider; - -import android.app.backup.BackupDataInput; -import android.content.Context; -import android.content.pm.PackageInfo; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.util.Base64; -import android.util.Log; - -import com.stevesoltys.backup.security.CipherUtil; -import com.stevesoltys.backup.security.KeyGenerator; -import com.stevesoltys.backup.transport.component.BackupComponent; - -import org.apache.commons.io.IOUtils; - -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.text.SimpleDateFormat; -import java.util.Arrays; -import java.util.Date; -import java.util.Locale; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; - -import javax.crypto.Cipher; -import javax.crypto.SecretKey; - -import libcore.io.IoUtils; - -import static android.app.backup.BackupTransport.FLAG_INCREMENTAL; -import static android.app.backup.BackupTransport.FLAG_NON_INCREMENTAL; -import static android.app.backup.BackupTransport.TRANSPORT_ERROR; -import static android.app.backup.BackupTransport.TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED; -import static android.app.backup.BackupTransport.TRANSPORT_OK; -import static android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED; -import static android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED; -import static android.provider.DocumentsContract.buildDocumentUriUsingTree; -import static android.provider.DocumentsContract.createDocument; -import static android.provider.DocumentsContract.getTreeDocumentId; -import static com.stevesoltys.backup.activity.MainActivityController.DOCUMENT_MIME_TYPE; -import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupFolderUri; -import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword; -import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_BACKUP_QUOTA; -import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY; -import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_INCREMENTAL_BACKUP_DIRECTORY; -import static java.util.Objects.requireNonNull; - -/** - * @author Steve Soltys - */ -public class ContentProviderBackupComponent implements BackupComponent { - - private static final String TAG = ContentProviderBackupComponent.class.getSimpleName(); - - private static final String DOCUMENT_SUFFIX = "yyyy-MM-dd_HH_mm_ss"; - - private static final String DESTINATION_DESCRIPTION = "Backing up to zip file"; - - private static final String TRANSPORT_DATA_MANAGEMENT_LABEL = ""; - - private static final int INITIAL_BUFFER_SIZE = 512; - - private final Context context; - - private ContentProviderBackupState backupState; - - public ContentProviderBackupComponent(Context context) { - this.context = context; - } - - @Override - public void cancelFullBackup() { - clearBackupState(false); - } - - @Override - public int checkFullBackupSize(long size) { - int result = TRANSPORT_OK; - - if (size <= 0) { - result = TRANSPORT_PACKAGE_REJECTED; - - } else if (size > DEFAULT_BACKUP_QUOTA) { - result = TRANSPORT_QUOTA_EXCEEDED; - } - - return result; - } - - @Override - public int clearBackupData(PackageInfo packageInfo) { - return TRANSPORT_OK; - } - - @Override - public String currentDestinationString() { - return DESTINATION_DESCRIPTION; - } - - @Override - public String dataManagementLabel() { - return TRANSPORT_DATA_MANAGEMENT_LABEL; - } - - @Override - public int finishBackup() { - return clearBackupState(false); - } - - @Override - public long getBackupQuota(String packageName, boolean fullBackup) { - return DEFAULT_BACKUP_QUOTA; - } - - @Override - public int initializeDevice() { - return TRANSPORT_OK; - } - - @Override - public int performFullBackup(PackageInfo targetPackage, ParcelFileDescriptor fileDescriptor) { - - if (backupState != null && backupState.getInputFileDescriptor() != null) { - Log.e(TAG, "Attempt to initiate full backup while one is in progress"); - return TRANSPORT_ERROR; - } - - try { - initializeBackupState(); - backupState.setPackageName(targetPackage.packageName); - - backupState.setInputFileDescriptor(fileDescriptor); - backupState.setInputStream(new FileInputStream(fileDescriptor.getFileDescriptor())); - backupState.setBytesTransferred(0); - - Cipher cipher = CipherUtil.startEncrypt(backupState.getSecretKey(), backupState.getSalt()); - backupState.setCipher(cipher); - - ZipEntry zipEntry = new ZipEntry(DEFAULT_FULL_BACKUP_DIRECTORY + backupState.getPackageName()); - backupState.getOutputStream().putNextEntry(zipEntry); - - } catch (Exception ex) { - Log.e(TAG, "Error creating backup file for " + targetPackage.packageName + ": ", ex); - clearBackupState(true); - return TRANSPORT_ERROR; - } - - return TRANSPORT_OK; - } - - @Override - public int performIncrementalBackup(PackageInfo packageInfo, ParcelFileDescriptor data, int flags) { - boolean isIncremental = (flags & FLAG_INCREMENTAL) != 0; - if (isIncremental) { - Log.w(TAG, "Can not handle incremental backup. Requesting non-incremental for " + packageInfo.packageName); - return TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED; - } - - boolean isNonIncremental = (flags & FLAG_NON_INCREMENTAL) != 0; - if (isNonIncremental) { - Log.i(TAG, "Performing non-incremental backup for " + packageInfo.packageName); - } else { - Log.i(TAG, "Performing backup for " + packageInfo.packageName); - } - - BackupDataInput backupDataInput = new BackupDataInput(data.getFileDescriptor()); - - try { - initializeBackupState(); - backupState.setPackageName(packageInfo.packageName); - - return transferIncrementalBackupData(backupDataInput); - - } catch (Exception ex) { - Log.e(TAG, "Error reading backup input: ", ex); - return TRANSPORT_ERROR; - } - } - - @Override - public long requestBackupTime() { - return 0; - } - - @Override - public long requestFullBackupTime() { - return 0; - } - - @Override - public int sendBackupData(int numBytes) { - - if (backupState == null) { - Log.e(TAG, "Attempted sendBackupData() before performFullBackup()"); - return TRANSPORT_ERROR; - } - - long bytesTransferred = backupState.getBytesTransferred() + numBytes; - - if (bytesTransferred > DEFAULT_BACKUP_QUOTA) { - return TRANSPORT_QUOTA_EXCEEDED; - } - - InputStream inputStream = backupState.getInputStream(); - ZipOutputStream outputStream = backupState.getOutputStream(); - - try { - byte[] payload = IOUtils.readFully(inputStream, numBytes); - - if (backupState.getCipher() != null) { - payload = backupState.getCipher().update(payload); - } - - outputStream.write(payload, 0, numBytes); - backupState.setBytesTransferred(bytesTransferred); - - } catch (Exception ex) { - Log.e(TAG, "Error handling backup data for " + backupState.getPackageName() + ": ", ex); - return TRANSPORT_ERROR; - } - return TRANSPORT_OK; - } - - private int transferIncrementalBackupData(BackupDataInput backupDataInput) throws IOException { - ZipOutputStream outputStream = backupState.getOutputStream(); - - int bufferSize = INITIAL_BUFFER_SIZE; - byte[] buffer = new byte[bufferSize]; - - while (backupDataInput.readNextHeader()) { - String chunkFileName = Base64.encodeToString(backupDataInput.getKey().getBytes(), Base64.DEFAULT); - int dataSize = backupDataInput.getDataSize(); - - if (dataSize >= 0) { - ZipEntry zipEntry = new ZipEntry(DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + - backupState.getPackageName() + "/" + chunkFileName); - outputStream.putNextEntry(zipEntry); - - if (dataSize > bufferSize) { - bufferSize = dataSize; - buffer = new byte[bufferSize]; - } - - backupDataInput.readEntityData(buffer, 0, dataSize); - - try { - if (backupState.getSecretKey() != null) { - byte[] payload = Arrays.copyOfRange(buffer, 0, dataSize); - SecretKey secretKey = backupState.getSecretKey(); - byte[] salt = backupState.getSalt(); - - outputStream.write(CipherUtil.encrypt(payload, secretKey, salt)); - - } else { - outputStream.write(buffer, 0, dataSize); - } - - } catch (Exception ex) { - Log.e(TAG, "Error performing incremental backup for " + backupState.getPackageName() + ": ", ex); - clearBackupState(true); - return TRANSPORT_ERROR; - } - } - } - - return TRANSPORT_OK; - } - - @Override - public void backupFinished() { - clearBackupState(true); - } - - private void initializeBackupState() throws Exception { - if (backupState == null) { - backupState = new ContentProviderBackupState(); - } - - if (backupState.getOutputStream() == null) { - initializeOutputStream(); - - ZipEntry saltZipEntry = new ZipEntry(ContentProviderBackupConstants.SALT_FILE_PATH); - backupState.getOutputStream().putNextEntry(saltZipEntry); - backupState.getOutputStream().write(backupState.getSalt()); - backupState.getOutputStream().closeEntry(); - - String password = requireNonNull(getBackupPassword(context)); - backupState.setSecretKey(KeyGenerator.generate(password, backupState.getSalt())); - } - } - - private void initializeOutputStream() throws IOException { - Uri folderUri = getBackupFolderUri(context); - // TODO notify about failure with notification - Uri fileUri = createBackupFile(folderUri); - - ParcelFileDescriptor outputFileDescriptor = context.getContentResolver().openFileDescriptor(fileUri, "w"); - if (outputFileDescriptor == null) throw new IOException(); - backupState.setOutputFileDescriptor(outputFileDescriptor); - - FileOutputStream fileOutputStream = new FileOutputStream(outputFileDescriptor.getFileDescriptor()); - ZipOutputStream zipOutputStream = new ZipOutputStream(fileOutputStream); - backupState.setOutputStream(zipOutputStream); - } - - private Uri createBackupFile(Uri folderUri) throws IOException { - Uri documentUri = buildDocumentUriUsingTree(folderUri, getTreeDocumentId(folderUri)); - try { - Uri fileUri = createDocument(context.getContentResolver(), documentUri, DOCUMENT_MIME_TYPE, getBackupFileName()); - if (fileUri == null) throw new IOException(); - return fileUri; - - } catch (SecurityException e) { - // happens when folder was deleted and thus Uri permission don't exist anymore - throw new IOException(e); - } - } - - private String getBackupFileName() { - SimpleDateFormat dateFormat = new SimpleDateFormat(DOCUMENT_SUFFIX, Locale.US); - String date = dateFormat.format(new Date()); - return "backup-" + date; - } - - private int clearBackupState(boolean closeFile) { - - if (backupState == null) { - return TRANSPORT_OK; - } - - try { - IoUtils.closeQuietly(backupState.getInputFileDescriptor()); - backupState.setInputFileDescriptor(null); - - ZipOutputStream outputStream = backupState.getOutputStream(); - - if (outputStream != null) { - - if (backupState.getCipher() != null) { - outputStream.write(backupState.getCipher().doFinal()); - backupState.setCipher(null); - } - - outputStream.closeEntry(); - } - if (closeFile) { - Log.d(TAG, "Closing backup file..."); - if (outputStream != null) { - outputStream.finish(); - outputStream.close(); - } - - IoUtils.closeQuietly(backupState.getOutputFileDescriptor()); - backupState = null; - } - - } catch (Exception ex) { - Log.e(TAG, "Error cancelling full backup: ", ex); - return TRANSPORT_ERROR; - } - - return TRANSPORT_OK; - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupConstants.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupConstants.java deleted file mode 100644 index 4020f9d9..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupConstants.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.stevesoltys.backup.transport.component.provider; - -/** - * @author Steve Soltys - */ -public interface ContentProviderBackupConstants { - - String SALT_FILE_PATH = "salt"; - - String DEFAULT_FULL_BACKUP_DIRECTORY = "full/"; - - String DEFAULT_INCREMENTAL_BACKUP_DIRECTORY = "incr/"; - - long DEFAULT_BACKUP_QUOTA = Long.MAX_VALUE; - -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupState.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupState.java deleted file mode 100644 index 0adc8d17..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderBackupState.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.stevesoltys.backup.transport.component.provider; - -import android.os.ParcelFileDescriptor; - -import java.io.InputStream; -import java.security.SecureRandom; -import java.util.zip.ZipOutputStream; - -import javax.crypto.Cipher; -import javax.crypto.SecretKey; - -/** - * @author Steve Soltys - */ -class ContentProviderBackupState { - - private static final SecureRandom SECURE_RANDOM = new SecureRandom(); - - private ParcelFileDescriptor inputFileDescriptor; - - private ParcelFileDescriptor outputFileDescriptor; - - private InputStream inputStream; - - private ZipOutputStream outputStream; - - private Cipher cipher; - - private long bytesTransferred; - - private String packageName; - - private byte[] salt; - - private SecretKey secretKey; - - ContentProviderBackupState() { - salt = new byte[16]; - SECURE_RANDOM.nextBytes(salt); - } - - long getBytesTransferred() { - return bytesTransferred; - } - - void setBytesTransferred(long bytesTransferred) { - this.bytesTransferred = bytesTransferred; - } - - Cipher getCipher() { - return cipher; - } - - void setCipher(Cipher cipher) { - this.cipher = cipher; - } - - ParcelFileDescriptor getInputFileDescriptor() { - return inputFileDescriptor; - } - - void setInputFileDescriptor(ParcelFileDescriptor inputFileDescriptor) { - this.inputFileDescriptor = inputFileDescriptor; - } - - InputStream getInputStream() { - return inputStream; - } - - void setInputStream(InputStream inputStream) { - this.inputStream = inputStream; - } - - ParcelFileDescriptor getOutputFileDescriptor() { - return outputFileDescriptor; - } - - void setOutputFileDescriptor(ParcelFileDescriptor outputFileDescriptor) { - this.outputFileDescriptor = outputFileDescriptor; - } - - ZipOutputStream getOutputStream() { - return outputStream; - } - - void setOutputStream(ZipOutputStream outputStream) { - this.outputStream = outputStream; - } - - String getPackageName() { - return packageName; - } - - void setPackageName(String packageName) { - this.packageName = packageName; - } - - byte[] getSalt() { - return salt; - } - - SecretKey getSecretKey() { - return secretKey; - } - - void setSecretKey(SecretKey secretKey) { - this.secretKey = secretKey; - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreComponent.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreComponent.java deleted file mode 100644 index f983bed9..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreComponent.java +++ /dev/null @@ -1,360 +0,0 @@ -package com.stevesoltys.backup.transport.component.provider; - -import android.annotation.Nullable; -import android.app.backup.BackupDataOutput; -import android.app.backup.RestoreDescription; -import android.app.backup.RestoreSet; -import android.content.ContentResolver; -import android.content.Context; -import android.content.pm.PackageInfo; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.util.Base64; -import android.util.Log; - -import com.android.internal.util.Preconditions; -import com.stevesoltys.backup.security.CipherUtil; -import com.stevesoltys.backup.security.KeyGenerator; -import com.stevesoltys.backup.transport.component.RestoreComponent; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.util.Arrays; -import java.util.LinkedList; -import java.util.List; -import java.util.Optional; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -import javax.crypto.SecretKey; - -import libcore.io.IoUtils; -import libcore.io.Streams; - -import static android.app.backup.BackupTransport.NO_MORE_DATA; -import static android.app.backup.BackupTransport.TRANSPORT_ERROR; -import static android.app.backup.BackupTransport.TRANSPORT_OK; -import static android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED; -import static android.app.backup.RestoreDescription.TYPE_FULL_STREAM; -import static android.app.backup.RestoreDescription.TYPE_KEY_VALUE; -import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_FULL_BACKUP_DIRECTORY; -import static com.stevesoltys.backup.transport.component.provider.ContentProviderBackupConstants.DEFAULT_INCREMENTAL_BACKUP_DIRECTORY; -import static java.util.Objects.requireNonNull; - -/** - * @author Steve Soltys - */ -public class ContentProviderRestoreComponent implements RestoreComponent { - - private static final String TAG = ContentProviderRestoreComponent.class.getName(); - - private static final int DEFAULT_RESTORE_SET = 1; - - private static final int DEFAULT_BUFFER_SIZE = 2048; - - @Nullable - private String password; - @Nullable - private Uri fileUri; - - private ContentProviderRestoreState restoreState; - - private final Context context; - - public ContentProviderRestoreComponent(Context context) { - this.context = context; - } - - @Override - public void prepareRestore(String password, Uri fileUri) { - this.password = password; - this.fileUri = fileUri; - } - - @Override - public int startRestore(long token, PackageInfo[] packages) { - restoreState = new ContentProviderRestoreState(); - restoreState.setPackages(packages); - restoreState.setPackageIndex(-1); - - String password = requireNonNull(this.password); - - if (!password.isEmpty()) { - try { - ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor(); - ZipInputStream inputStream = buildInputStream(inputFileDescriptor); - seekToEntry(inputStream, ContentProviderBackupConstants.SALT_FILE_PATH); - - restoreState.setSalt(Streams.readFullyNoClose(inputStream)); - restoreState.setSecretKey(KeyGenerator.generate(password, restoreState.getSalt())); - - IoUtils.closeQuietly(inputFileDescriptor); - IoUtils.closeQuietly(inputStream); - - } catch (Exception ex) { - Log.e(TAG, "Salt not found", ex); - } - } - - try { - List zipEntries = new LinkedList<>(); - - ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor(); - ZipInputStream inputStream = buildInputStream(inputFileDescriptor); - - ZipEntry zipEntry; - while ((zipEntry = inputStream.getNextEntry()) != null) { - zipEntries.add(zipEntry); - inputStream.closeEntry(); - } - - IoUtils.closeQuietly(inputFileDescriptor); - IoUtils.closeQuietly(inputStream); - - restoreState.setZipEntries(zipEntries); - - } catch (Exception ex) { - Log.e(TAG, "Error while caching zip entries", ex); - } - - return TRANSPORT_OK; - } - - @Override - public RestoreDescription nextRestorePackage() { - Preconditions.checkNotNull(restoreState, "startRestore() not called"); - - int packageIndex = restoreState.getPackageIndex(); - PackageInfo[] packages = restoreState.getPackages(); - - while (++packageIndex < packages.length) { - restoreState.setPackageIndex(packageIndex); - String name = packages[packageIndex].packageName; - - if (containsPackageFile(DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + name)) { - restoreState.setRestoreType(TYPE_KEY_VALUE); - return new RestoreDescription(name, restoreState.getRestoreType()); - - } else if (containsPackageFile(DEFAULT_FULL_BACKUP_DIRECTORY + name)) { - restoreState.setRestoreType(TYPE_FULL_STREAM); - return new RestoreDescription(name, restoreState.getRestoreType()); - } - } - return RestoreDescription.NO_MORE_PACKAGES; - } - - private boolean containsPackageFile(String fileName) { - return restoreState.getZipEntries().stream() - .anyMatch(zipEntry -> zipEntry.getName().startsWith(fileName)); - } - - @Override - public int getRestoreData(ParcelFileDescriptor outputFileDescriptor) { - Preconditions.checkState(restoreState != null, "startRestore() not called"); - Preconditions.checkState(restoreState.getPackageIndex() >= 0, "nextRestorePackage() not called"); - Preconditions.checkState(restoreState.getRestoreType() == TYPE_KEY_VALUE, - "getRestoreData() for non-key/value dataset"); - - PackageInfo packageInfo = restoreState.getPackages()[restoreState.getPackageIndex()]; - - try { - return transferIncrementalRestoreData(packageInfo.packageName, outputFileDescriptor); - - } catch (Exception ex) { - Log.e(TAG, "Unable to read backup records: ", ex); - return TRANSPORT_ERROR; - } - } - - private int transferIncrementalRestoreData(String packageName, ParcelFileDescriptor outputFileDescriptor) - throws Exception { - - ParcelFileDescriptor inputFileDescriptor = buildInputFileDescriptor(); - ZipInputStream inputStream = buildInputStream(inputFileDescriptor); - BackupDataOutput backupDataOutput = new BackupDataOutput(outputFileDescriptor.getFileDescriptor()); - - Optional zipEntryOptional = seekToEntry(inputStream, - DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + packageName); - - while (zipEntryOptional.isPresent()) { - String fileName = new File(zipEntryOptional.get().getName()).getName(); - String blobKey = new String(Base64.decode(fileName, Base64.DEFAULT)); - - byte[] backupData = readBackupData(inputStream); - backupDataOutput.writeEntityHeader(blobKey, backupData.length); - backupDataOutput.writeEntityData(backupData, backupData.length); - inputStream.closeEntry(); - - zipEntryOptional = seekToEntry(inputStream, DEFAULT_INCREMENTAL_BACKUP_DIRECTORY + packageName); - } - - IoUtils.closeQuietly(inputFileDescriptor); - IoUtils.closeQuietly(outputFileDescriptor); - return TRANSPORT_OK; - } - - private byte[] readBackupData(ZipInputStream inputStream) throws Exception { - byte[] backupData = Streams.readFullyNoClose(inputStream); - SecretKey secretKey = restoreState.getSecretKey(); - byte[] initializationVector = restoreState.getSalt(); - - if (secretKey != null) { - backupData = CipherUtil.decrypt(backupData, secretKey, initializationVector); - } - - return backupData; - } - - @Override - public int getNextFullRestoreDataChunk(ParcelFileDescriptor outputFileDescriptor) { - Preconditions.checkState(restoreState.getRestoreType() == TYPE_FULL_STREAM, - "Asked for full restore data for non-stream package"); - - ParcelFileDescriptor inputFileDescriptor = restoreState.getInputFileDescriptor(); - - if (inputFileDescriptor == null) { - String name = restoreState.getPackages()[restoreState.getPackageIndex()].packageName; - - try { - inputFileDescriptor = buildInputFileDescriptor(); - restoreState.setInputFileDescriptor(inputFileDescriptor); - - ZipInputStream inputStream = buildInputStream(inputFileDescriptor); - restoreState.setInputStream(inputStream); - - if (!seekToEntry(inputStream, DEFAULT_FULL_BACKUP_DIRECTORY + name).isPresent()) { - IoUtils.closeQuietly(inputFileDescriptor); - IoUtils.closeQuietly(outputFileDescriptor); - return TRANSPORT_PACKAGE_REJECTED; - } - - } catch (IOException ex) { - Log.e(TAG, "Unable to read archive for " + name, ex); - - IoUtils.closeQuietly(inputFileDescriptor); - IoUtils.closeQuietly(outputFileDescriptor); - return TRANSPORT_PACKAGE_REJECTED; - } - } - - return transferFullRestoreData(outputFileDescriptor); - } - - private int transferFullRestoreData(ParcelFileDescriptor outputFileDescriptor) { - ZipInputStream inputStream = restoreState.getInputStream(); - OutputStream outputStream = new FileOutputStream(outputFileDescriptor.getFileDescriptor()); - - byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; - int bytesRead = NO_MORE_DATA; - - try { - bytesRead = inputStream.read(buffer); - - if (bytesRead <= 0) { - bytesRead = NO_MORE_DATA; - - if (restoreState.getCipher() != null) { - buffer = restoreState.getCipher().doFinal(); - bytesRead = buffer.length; - - outputStream.write(buffer, 0, bytesRead); - restoreState.setCipher(null); - } - - } else { - if (restoreState.getSecretKey() != null) { - SecretKey secretKey = restoreState.getSecretKey(); - byte[] salt = restoreState.getSalt(); - - if (restoreState.getCipher() == null) { - restoreState.setCipher(CipherUtil.startDecrypt(secretKey, salt)); - } - - buffer = restoreState.getCipher().update(Arrays.copyOfRange(buffer, 0, bytesRead)); - bytesRead = buffer.length; - } - - outputStream.write(buffer, 0, bytesRead); - } - - } catch (Exception e) { - Log.e(TAG, "Exception while streaming restore data: ", e); - return TRANSPORT_ERROR; - - } finally { - if (bytesRead == NO_MORE_DATA) { - - if (restoreState.getInputFileDescriptor() != null) { - IoUtils.closeQuietly(restoreState.getInputFileDescriptor()); - } - - restoreState.setInputFileDescriptor(null); - restoreState.setInputStream(null); - } - - IoUtils.closeQuietly(outputFileDescriptor); - } - - return bytesRead; - } - - @Override - public int abortFullRestore() { - resetFullRestoreState(); - return TRANSPORT_OK; - } - - @Override - public long getCurrentRestoreSet() { - return DEFAULT_RESTORE_SET; - } - - @Override - public void finishRestore() { - if (restoreState.getRestoreType() == TYPE_FULL_STREAM) { - resetFullRestoreState(); - } - - restoreState = null; - } - - @Override - public RestoreSet[] getAvailableRestoreSets() { - return new RestoreSet[]{new RestoreSet("Local disk image", "flash", DEFAULT_RESTORE_SET)}; - } - - private void resetFullRestoreState() { - Preconditions.checkNotNull(restoreState); - Preconditions.checkState(restoreState.getRestoreType() == TYPE_FULL_STREAM); - - IoUtils.closeQuietly(restoreState.getInputFileDescriptor()); - restoreState = null; - } - - private ParcelFileDescriptor buildInputFileDescriptor() throws FileNotFoundException { - ContentResolver contentResolver = context.getContentResolver(); - return contentResolver.openFileDescriptor(requireNonNull(fileUri), "r"); - } - - private ZipInputStream buildInputStream(ParcelFileDescriptor inputFileDescriptor) { - FileInputStream fileInputStream = new FileInputStream(inputFileDescriptor.getFileDescriptor()); - return new ZipInputStream(fileInputStream); - } - - private Optional seekToEntry(ZipInputStream inputStream, String entryPath) throws IOException { - ZipEntry zipEntry; - while ((zipEntry = inputStream.getNextEntry()) != null) { - - if (zipEntry.getName().startsWith(entryPath)) { - return Optional.of(zipEntry); - } - inputStream.closeEntry(); - } - - return Optional.empty(); - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreState.java b/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreState.java deleted file mode 100644 index a7b6e3c6..00000000 --- a/app/src/main/java/com/stevesoltys/backup/transport/component/provider/ContentProviderRestoreState.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.stevesoltys.backup.transport.component.provider; - -import android.content.pm.PackageInfo; -import android.os.ParcelFileDescriptor; - -import javax.crypto.Cipher; -import javax.crypto.SecretKey; -import java.util.List; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; - -/** - * @author Steve Soltys - */ -class ContentProviderRestoreState { - - private ParcelFileDescriptor inputFileDescriptor; - - private PackageInfo[] packages; - - private int packageIndex; - - private int restoreType; - - private ZipInputStream inputStream; - - private Cipher cipher; - - private byte[] salt; - - private SecretKey secretKey; - - private List zipEntries; - - Cipher getCipher() { - return cipher; - } - - ParcelFileDescriptor getInputFileDescriptor() { - return inputFileDescriptor; - } - - void setCipher(Cipher cipher) { - this.cipher = cipher; - } - - void setInputFileDescriptor(ParcelFileDescriptor inputFileDescriptor) { - this.inputFileDescriptor = inputFileDescriptor; - } - - ZipInputStream getInputStream() { - return inputStream; - } - - void setInputStream(ZipInputStream inputStream) { - this.inputStream = inputStream; - } - - int getPackageIndex() { - return packageIndex; - } - - void setPackageIndex(int packageIndex) { - this.packageIndex = packageIndex; - } - - PackageInfo[] getPackages() { - return packages; - } - - void setPackages(PackageInfo[] packages) { - this.packages = packages; - } - - int getRestoreType() { - return restoreType; - } - - void setRestoreType(int restoreType) { - this.restoreType = restoreType; - } - - byte[] getSalt() { - return salt; - } - - void setSalt(byte[] salt) { - this.salt = salt; - } - - public SecretKey getSecretKey() { - return secretKey; - } - - public void setSecretKey(SecretKey secretKey) { - this.secretKey = secretKey; - } - - public List getZipEntries() { - return zipEntries; - } - - public void setZipEntries(List zipEntries) { - this.zipEntries = zipEntries; - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestore.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestore.kt new file mode 100644 index 00000000..66588567 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestore.kt @@ -0,0 +1,172 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.BackupTransport.* +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import android.util.Log +import com.stevesoltys.backup.crypto.Crypto +import com.stevesoltys.backup.header.HeaderReader +import com.stevesoltys.backup.header.UnsupportedVersionException +import libcore.io.IoUtils.closeQuietly +import java.io.EOFException +import java.io.IOException +import java.io.InputStream + +private class FullRestoreState( + internal val token: Long, + internal val packageInfo: PackageInfo) { + internal var inputStream: InputStream? = null +} + +private val TAG = FullRestore::class.java.simpleName + +internal class FullRestore( + private val plugin: FullRestorePlugin, + private val outputFactory: OutputFactory, + private val headerReader: HeaderReader, + private val crypto: Crypto) { + + private var state: FullRestoreState? = null + + fun hasState() = state != null + + /** + * Return true if there is data stored for the given package. + */ + @Throws(IOException::class) + fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { + return plugin.hasDataForPackage(token, packageInfo) + } + + /** + * This prepares to restore the given package from the given restore token. + * + * It is possible that the system decides to not restore the package. + * Then a new state will be initialized right away without calling other methods. + */ + fun initializeState(token: Long, packageInfo: PackageInfo) { + state = FullRestoreState(token, packageInfo) + } + + /** + * Ask the transport to provide data for the "current" package being restored. + * + * The transport writes some data to the socket supplied to this call, + * and returns the number of bytes written. + * The system will then read that many bytes + * and stream them to the application's agent for restore, + * then will call this method again to receive the next chunk of the archive. + * This sequence will be repeated until the transport returns zero + * indicating that all of the package's data has been delivered + * (or returns a negative value indicating a hard error condition at the transport level). + * + * The transport should always close this socket when returning from this method. + * Do not cache this socket across multiple calls or you may leak file descriptors. + * + * @param socket The file descriptor for delivering the streamed archive. + * The transport must close this socket in all cases when returning from this method. + * @return [NO_MORE_DATA] when no more data for the current package is available. + * A positive value indicates the presence of that many bytes to be delivered to the app. + * A value of zero indicates that no data was deliverable at this time, + * but the restore is still running and the caller should retry. + * [TRANSPORT_PACKAGE_REJECTED] means that the package's restore operation should be aborted, + * but that the transport itself is still in a good state + * and so a multiple-package restore sequence can still be continued. + * Any other negative value such as [TRANSPORT_ERROR] is treated as a fatal error condition + * that aborts all further restore operations on the current dataset. + */ + fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int { + Log.i(TAG, "Get next full restore data chunk.") + val state = this.state ?: throw IllegalStateException() + val packageName = state.packageInfo.packageName + + if (state.inputStream == null) { + Log.i(TAG, "First Chunk, initializing package input stream.") + try { + val inputStream = plugin.getInputStreamForPackage(state.token, state.packageInfo) + val version = headerReader.readVersion(inputStream) + crypto.decryptHeader(inputStream, version, packageName) + state.inputStream = inputStream + } catch (e: IOException) { + Log.w(TAG, "Error getting input stream for $packageName", e) + return TRANSPORT_PACKAGE_REJECTED + } catch (e: SecurityException) { + Log.e(TAG, "Security Exception while getting input stream for $packageName", e) + return TRANSPORT_ERROR + } catch (e: UnsupportedVersionException) { + Log.e(TAG, "Backup data for $packageName uses unsupported version ${e.version}.", e) + return TRANSPORT_PACKAGE_REJECTED + } + } + + return readInputStream(socket) + } + + private fun readInputStream(socket: ParcelFileDescriptor): Int = socket.use { fileDescriptor -> + val state = this.state ?: throw IllegalStateException() + val packageName = state.packageInfo.packageName + val inputStream = state.inputStream ?: throw IllegalStateException() + val outputStream = outputFactory.getOutputStream(fileDescriptor) + + try { + // read segment from input stream and decrypt it + val decrypted = try { + crypto.decryptSegment(inputStream) + } catch (e: EOFException) { + Log.i(TAG, " EOF") + // close input stream here as we won't need it anymore + closeQuietly(inputStream) + return NO_MORE_DATA + } + + // write decrypted segment to output stream (without header) + outputStream.write(decrypted) + // return number of written bytes + return decrypted.size + } catch (e: IOException) { + Log.w(TAG, "Error processing stream for package $packageName.", e) + closeQuietly(inputStream) + return TRANSPORT_PACKAGE_REJECTED + } finally { + closeQuietly(outputStream) + } + } + + /** + * If the OS encounters an error while processing full data for restore, + * it will invoke this method + * to tell the transport that it should abandon the data download for the current package. + * + * @return [TRANSPORT_OK] if the transport shut down the current stream cleanly, + * or [TRANSPORT_ERROR] to indicate a serious transport-level failure. + * If the transport reports an error here, + * the entire restore operation will immediately be finished + * with no further attempts to restore app data. + */ + fun abortFullRestore(): Int { + val state = this.state ?: throw IllegalStateException() + Log.i(TAG, "Abort full restore of ${state.packageInfo.packageName}!") + + resetState() + return TRANSPORT_OK + } + + /** + * End a restore session (aborting any in-process data transfer as necessary), + * freeing any resources and connections used during the restore process. + */ + fun finishRestore() { + val state = this.state ?: throw IllegalStateException() + Log.i(TAG, "Finish restore of ${state.packageInfo.packageName}!") + + resetState() + } + + private fun resetState() { + Log.i(TAG, "Resetting state.") + + closeQuietly(state?.inputStream) + state = null + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestorePlugin.kt new file mode 100644 index 00000000..9281d76c --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/FullRestorePlugin.kt @@ -0,0 +1,18 @@ +package com.stevesoltys.backup.transport.restore + +import android.content.pm.PackageInfo +import java.io.IOException +import java.io.InputStream + +interface FullRestorePlugin { + + /** + * Return true if there is data stored for the given package. + */ + @Throws(IOException::class) + fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean + + @Throws(IOException::class) + fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt new file mode 100644 index 00000000..67f6b1a5 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestore.kt @@ -0,0 +1,140 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.BackupDataOutput +import android.app.backup.BackupTransport.TRANSPORT_ERROR +import android.app.backup.BackupTransport.TRANSPORT_OK +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import android.util.Log +import com.stevesoltys.backup.crypto.Crypto +import com.stevesoltys.backup.header.HeaderReader +import com.stevesoltys.backup.header.UnsupportedVersionException +import libcore.io.IoUtils.closeQuietly +import java.io.IOException +import java.util.* +import java.util.Base64.getUrlDecoder + +private class KVRestoreState( + internal val token: Long, + internal val packageInfo: PackageInfo) + +private val TAG = KVRestore::class.java.simpleName + +internal class KVRestore( + private val plugin: KVRestorePlugin, + private val outputFactory: OutputFactory, + private val headerReader: HeaderReader, + private val crypto: Crypto) { + + private var state: KVRestoreState? = null + + /** + * Return true if there are records stored for the given package. + */ + @Throws(IOException::class) + fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { + return plugin.hasDataForPackage(token, packageInfo) + } + + /** + * This prepares to restore the given package from the given restore token. + * + * It is possible that the system decides to not restore the package. + * Then a new state will be initialized right away without calling other methods. + */ + fun initializeState(token: Long, packageInfo: PackageInfo) { + state = KVRestoreState(token, packageInfo) + } + + /** + * Get the data for the current package. + * + * @param data An open, writable file into which the key/value backup data should be stored. + * @return One of [TRANSPORT_OK] + * or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled). + */ + fun getRestoreData(data: ParcelFileDescriptor): Int { + val state = this.state ?: throw IllegalStateException() + + // The restore set is the concatenation of the individual record blobs, + // each of which is a file in the package's directory. + // We return the data in lexical order sorted by key, + // so that apps which use synthetic keys like BLOB_1, BLOB_2, etc + // will see the date in the most obvious order. + val sortedKeys = getSortedKeys(state.token, state.packageInfo) + if (sortedKeys == null) { + // nextRestorePackage() ensures the dir exists, so this is an error + Log.e(TAG, "No keys for package: ${state.packageInfo.packageName}") + return TRANSPORT_ERROR + } + + // We expect at least some data if the directory exists in the first place + Log.v(TAG, " getRestoreData() found ${sortedKeys.size} key files") + + return try { + val dataOutput = outputFactory.getBackupDataOutput(data) + for (keyEntry in sortedKeys) { + readAndWriteValue(state, keyEntry, dataOutput) + } + TRANSPORT_OK + } catch (e: IOException) { + Log.e(TAG, "Unable to read backup records", e) + TRANSPORT_ERROR + } catch (e: SecurityException) { + Log.e(TAG, "Security exception while reading backup records", e) + TRANSPORT_ERROR + } catch (e: UnsupportedVersionException) { + Log.e(TAG, "Unsupported version in backup: ${e.version}", e) + TRANSPORT_ERROR + } finally { + this.state = null + closeQuietly(data) + } + } + + /** + * Return a list of the records (represented by key files) in the given directory, + * sorted lexically by the Base64-decoded key file name, not by the on-disk filename. + */ + private fun getSortedKeys(token: Long, packageInfo: PackageInfo): List? { + val records: List = try { + plugin.listRecords(token, packageInfo) + } catch (e: IOException) { + return null + } + if (records.isEmpty()) return null + + // Decode the key filenames into keys then sort lexically by key + val contents = ArrayList() + for (recordKey in records) contents.add(DecodedKey(recordKey)) + contents.sort() + return contents + } + + /** + * Read the encrypted value for the given key and write it to the given [BackupDataOutput]. + */ + @Throws(IOException::class, UnsupportedVersionException::class, SecurityException::class) + private fun readAndWriteValue(state: KVRestoreState, dKey: DecodedKey, out: BackupDataOutput) { + val inputStream = plugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key) + try { + val version = headerReader.readVersion(inputStream) + crypto.decryptHeader(inputStream, version, state.packageInfo.packageName, dKey.key) + val value = crypto.decryptSegment(inputStream) + val size = value.size + Log.v(TAG, " ... key=${dKey.key} size=$size") + + out.writeEntityHeader(dKey.key, size) + out.writeEntityData(value, size) + } finally { + closeQuietly(inputStream) + } + } + + private class DecodedKey(internal val base64Key: String) : Comparable { + internal val key = String(getUrlDecoder().decode(base64Key)) + + override fun compareTo(other: DecodedKey) = key.compareTo(other.key) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestorePlugin.kt new file mode 100644 index 00000000..e425752c --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/KVRestorePlugin.kt @@ -0,0 +1,30 @@ +package com.stevesoltys.backup.transport.restore + +import android.content.pm.PackageInfo +import java.io.IOException +import java.io.InputStream + +interface KVRestorePlugin { + + /** + * Return true if there is data stored for the given package. + */ + @Throws(IOException::class) + fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean + + /** + * Return all record keys for the given token and package. + * + * For file-based plugins, this is usually a list of file names in the package directory. + */ + @Throws(IOException::class) + fun listRecords(token: Long, packageInfo: PackageInfo): List + + /** + * Return an [InputStream] for the given token, package and key + * which will provide the record's encrypted value. + */ + @Throws(IOException::class) + fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/OutputFactory.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/OutputFactory.kt new file mode 100644 index 00000000..16162bb6 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/OutputFactory.kt @@ -0,0 +1,21 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.BackupDataOutput +import android.os.ParcelFileDescriptor +import java.io.FileOutputStream +import java.io.OutputStream + +/** + * This class exists for easier testing, so we can mock it and return custom data outputs. + */ +class OutputFactory { + + fun getBackupDataOutput(outputFileDescriptor: ParcelFileDescriptor): BackupDataOutput { + return BackupDataOutput(outputFileDescriptor.fileDescriptor) + } + + fun getOutputStream(outputFileDescriptor: ParcelFileDescriptor): OutputStream { + return FileOutputStream(outputFileDescriptor.fileDescriptor) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt new file mode 100644 index 00000000..d6dc397a --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestoreCoordinator.kt @@ -0,0 +1,155 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.BackupTransport.TRANSPORT_ERROR +import android.app.backup.BackupTransport.TRANSPORT_OK +import android.app.backup.RestoreDescription +import android.app.backup.RestoreDescription.* +import android.app.backup.RestoreSet +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import android.util.Log +import java.io.IOException + +private class RestoreCoordinatorState( + internal val token: Long, + internal val packages: Iterator) + +private val TAG = RestoreCoordinator::class.java.simpleName + +internal class RestoreCoordinator( + private val plugin: RestorePlugin, + private val kv: KVRestore, + private val full: FullRestore) { + + private var state: RestoreCoordinatorState? = null + + fun getAvailableRestoreSets(): Array? { + return plugin.getAvailableRestoreSets() + .apply { Log.i(TAG, "Got available restore sets: $this") } + } + + fun getCurrentRestoreSet(): Long { + return plugin.getCurrentRestoreSet() + .apply { Log.i(TAG, "Got current restore set token: $this") } + } + + /** + * Start restoring application data from backup. + * After calling this function, + * there will be alternate calls to [nextRestorePackage] and [getRestoreData] + * to walk through the actual application data. + * + * @param token A backup token as returned by [getAvailableRestoreSets] or [getCurrentRestoreSet]. + * @param packages List of applications to restore (if data is available). + * Application data will be restored in the order given. + * @return One of [TRANSPORT_OK] (OK so far, call [nextRestorePackage]) + * or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled). + */ + fun startRestore(token: Long, packages: Array): Int { + if (state != null) throw IllegalStateException() + Log.i(TAG, "Start restore with ${packages.map { info -> info.packageName }}") + state = RestoreCoordinatorState(token, packages.iterator()) + return TRANSPORT_OK + } + + /** + * Get the package name of the next package with data in the backup store, + * plus a description of the structure of the restored archive: + * either [TYPE_KEY_VALUE] for an original-API key/value dataset, + * or [TYPE_FULL_STREAM] for a tarball-type archive stream. + * + * If the package name in the returned [RestoreDescription] object is [NO_MORE_PACKAGES], + * it indicates that no further data is available in the current restore session, + * i.e. all packages described in [startRestore] have been processed. + * + * If this method returns null, it means that a transport-level error has + * occurred and the entire restore operation should be abandoned. + * + * The OS may call [nextRestorePackage] multiple times + * before calling either [getRestoreData] or [getNextFullRestoreDataChunk]. + * It does this when it has determined + * that it needs to skip restore of one or more packages. + * The transport should not actually transfer any restore data + * for the given package in response to [nextRestorePackage], + * but rather wait for an explicit request before doing so. + * + * @return A [RestoreDescription] object containing the name of one of the packages + * supplied to [startRestore] plus an indicator of the data type of that restore data; + * or [NO_MORE_PACKAGES] to indicate that no more packages can be restored in this session; + * or null to indicate a transport-level error. + */ + fun nextRestorePackage(): RestoreDescription? { + Log.i(TAG, "Next restore package!") + val state = this.state ?: throw IllegalStateException() + + if (!state.packages.hasNext()) return NO_MORE_PACKAGES + val packageInfo = state.packages.next() + val packageName = packageInfo.packageName + + val type = try { + when { + // check key/value data first and if available, don't even check for full data + kv.hasDataForPackage(state.token, packageInfo) -> { + Log.i(TAG, "Found K/V data for $packageName.") + kv.initializeState(state.token, packageInfo) + TYPE_KEY_VALUE + } + full.hasDataForPackage(state.token, packageInfo) -> { + Log.i(TAG, "Found full backup data for $packageName.") + full.initializeState(state.token, packageInfo) + TYPE_FULL_STREAM + } + else -> { + Log.i(TAG, "No data found for $packageName. Skipping.") + return nextRestorePackage() + } + } + } catch (e: IOException) { + Log.e(TAG, "Error finding restore data for $packageName.", e) + return null + } + return RestoreDescription(packageName, type) + } + + /** + * Get the data for the application returned by [nextRestorePackage], + * if that method reported [TYPE_KEY_VALUE] as its delivery type. + * If the package has only TYPE_FULL_STREAM data, then this method will return an error. + * + * @param data An open, writable file into which the key/value backup data should be stored. + * @return the same error codes as [startRestore]. + */ + fun getRestoreData(data: ParcelFileDescriptor): Int { + return kv.getRestoreData(data) + } + + /** + * Ask the transport to provide data for the "current" package being restored. + * + * After this method returns zero, the system will then call [nextRestorePackage] + * to begin the restore process for the next application, and the sequence begins again. + */ + fun getNextFullRestoreDataChunk(outputFileDescriptor: ParcelFileDescriptor): Int { + return full.getNextFullRestoreDataChunk(outputFileDescriptor) + } + + /** + * If the OS encounters an error while processing full data for restore, it will abort. + * + * The OS will then either call [nextRestorePackage] again to move on + * to restoring the next package in the set being iterated over, + * or will call [finishRestore] to shut down the restore operation. + */ + fun abortFullRestore(): Int { + return full.abortFullRestore() + } + + /** + * End a restore session (aborting any in-process data transfer as necessary), + * freeing any resources and connections used during the restore process. + */ + fun finishRestore() { + if (full.hasState()) full.finishRestore() + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt new file mode 100644 index 00000000..17f4f0ad --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/RestorePlugin.kt @@ -0,0 +1,28 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.RestoreSet + +interface RestorePlugin { + + val kvRestorePlugin: KVRestorePlugin + + val fullRestorePlugin: FullRestorePlugin + + /** + * Get the set of all backups currently available for restore. + * + * @return Descriptions of the set of restore images available for this device, + * or null if an error occurred (the attempt should be rescheduled). + **/ + fun getAvailableRestoreSets(): Array? + + /** + * Get the identifying token of the backup set currently being stored from this device. + * This is used in the case of applications wishing to restore their last-known-good data. + * + * @return A token that can be used for restore, + * or 0 if there is no backup set available corresponding to the current device state. + */ + fun getCurrentRestoreSet(): Long + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderFullRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderFullRestorePlugin.kt new file mode 100644 index 00000000..83825a06 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderFullRestorePlugin.kt @@ -0,0 +1,25 @@ +package com.stevesoltys.backup.transport.restore.plugins + +import android.content.pm.PackageInfo +import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage +import com.stevesoltys.backup.transport.restore.FullRestorePlugin +import java.io.IOException +import java.io.InputStream + +class DocumentsProviderFullRestorePlugin( + private val documentsStorage: DocumentsStorage) : FullRestorePlugin { + + @Throws(IOException::class) + override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { + val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException() + return backupDir.findFile(packageInfo.packageName) != null + } + + @Throws(IOException::class) + override fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream { + val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException() + val packageFile = backupDir.findFile(packageInfo.packageName) ?: throw IOException() + return documentsStorage.getInputStream(packageFile) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderKVRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderKVRestorePlugin.kt new file mode 100644 index 00000000..d24b1bd1 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderKVRestorePlugin.kt @@ -0,0 +1,42 @@ +package com.stevesoltys.backup.transport.restore.plugins + +import android.content.pm.PackageInfo +import androidx.documentfile.provider.DocumentFile +import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage +import com.stevesoltys.backup.transport.backup.plugins.assertRightFile +import com.stevesoltys.backup.transport.restore.KVRestorePlugin +import java.io.IOException +import java.io.InputStream + +class DocumentsProviderKVRestorePlugin(private val storage: DocumentsStorage) : KVRestorePlugin { + + private var packageDir: DocumentFile? = null + + override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { + return try { + val backupDir = storage.getKVBackupDir(token) ?: return false + // remember package file for subsequent operations + packageDir = backupDir.findFile(packageInfo.packageName) + packageDir != null + } catch (e: IOException) { + false + } + } + + override fun listRecords(token: Long, packageInfo: PackageInfo): List { + val packageDir = this.packageDir ?: throw AssertionError() + packageDir.assertRightFile(packageInfo) + return packageDir.listFiles() + .filter { file -> file.name != null } + .map { file -> file.name!! } + } + + @Throws(IOException::class) + override fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream { + val packageDir = this.packageDir ?: throw AssertionError() + packageDir.assertRightFile(packageInfo) + val keyFile = packageDir.findFile(key) ?: throw IOException() + return storage.getInputStream(keyFile) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt new file mode 100644 index 00000000..deb9e327 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/transport/restore/plugins/DocumentsProviderRestorePlugin.kt @@ -0,0 +1,29 @@ +package com.stevesoltys.backup.transport.restore.plugins + +import android.app.backup.RestoreSet +import com.stevesoltys.backup.transport.DEFAULT_RESTORE_SET_TOKEN +import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage +import com.stevesoltys.backup.transport.restore.FullRestorePlugin +import com.stevesoltys.backup.transport.restore.KVRestorePlugin +import com.stevesoltys.backup.transport.restore.RestorePlugin + +class DocumentsProviderRestorePlugin( + private val documentsStorage: DocumentsStorage) : RestorePlugin { + + override val kvRestorePlugin: KVRestorePlugin by lazy { + DocumentsProviderKVRestorePlugin(documentsStorage) + } + + override val fullRestorePlugin: FullRestorePlugin by lazy { + DocumentsProviderFullRestorePlugin(documentsStorage) + } + + override fun getAvailableRestoreSets(): Array? { + return arrayOf(RestoreSet("default", "device", DEFAULT_RESTORE_SET_TOKEN)) + } + + override fun getCurrentRestoreSet(): Long { + return DEFAULT_RESTORE_SET_TOKEN + } + +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2c8e82e3..e06e4f21 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,6 +26,7 @@ Backup my data Backup location + Choose backup location Backup Location Choose where to store your backups. More options might get added in the future. External Storage diff --git a/app/src/test/java/com/stevesoltys/backup/TestUtils.kt b/app/src/test/java/com/stevesoltys/backup/TestUtils.kt new file mode 100644 index 00000000..6644f3b1 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/TestUtils.kt @@ -0,0 +1,38 @@ +package com.stevesoltys.backup + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.fail +import kotlin.random.Random + +fun assertContains(stack: String?, needle: String) { + assertTrue(stack?.contains(needle) ?: fail()) +} + +fun getRandomByteArray(size: Int = Random.nextInt(1337)) = ByteArray(size).apply { + Random.nextBytes(this) +} + +private val charPool : List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + '_' + '.' + +fun getRandomString(size: Int = Random.nextInt(1, 255)): String { + return (1..size) + .map { Random.nextInt(0, charPool.size) } + .map(charPool::get) + .joinToString("") +} + +fun ByteArray.toHexString(): String { + var str = "" + for (b in this) { + str += String.format("%02X ", b) + } + return str +} + +fun ByteArray.toIntString(): String { + var str = "" + for (b in this) { + str += String.format("%02d ", b) + } + return str +} diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/CryptoImplTest.kt b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoImplTest.kt new file mode 100644 index 00000000..e606bb67 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoImplTest.kt @@ -0,0 +1,53 @@ +package com.stevesoltys.backup.crypto + +import com.stevesoltys.backup.header.HeaderReaderImpl +import com.stevesoltys.backup.header.HeaderWriterImpl +import com.stevesoltys.backup.header.IV_SIZE +import com.stevesoltys.backup.header.MAX_SEGMENT_LENGTH +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import javax.crypto.Cipher +import kotlin.random.Random + +@TestInstance(PER_METHOD) +class CryptoImplTest { + + private val cipherFactory = mockk() + private val headerWriter = HeaderWriterImpl() + private val headerReader = HeaderReaderImpl() + + private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader) + + private val cipher = mockk() + + private val iv = ByteArray(IV_SIZE).apply { Random.nextBytes(this) } + private val cleartext = ByteArray(Random.nextInt(Short.MAX_VALUE.toInt())) + .apply { Random.nextBytes(this) } + private val ciphertext = ByteArray(Random.nextInt(Short.MAX_VALUE.toInt())) + .apply { Random.nextBytes(this) } + private val outputStream = ByteArrayOutputStream() + + @Test + fun `encrypted cleartext gets decrypted as expected`() { + every { cipherFactory.createEncryptionCipher() } returns cipher + every { cipher.getOutputSize(cleartext.size) } returns MAX_SEGMENT_LENGTH + every { cipher.doFinal(cleartext) } returns ciphertext + every { cipher.iv } returns iv + + crypto.encryptSegment(outputStream, cleartext) + + val inputStream = ByteArrayInputStream(outputStream.toByteArray()) + + every { cipherFactory.createDecryptionCipher(iv) } returns cipher + every { cipher.doFinal(ciphertext) } returns cleartext + + assertArrayEquals(cleartext, crypto.decryptSegment(inputStream)) + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/CryptoIntegrationTest.kt b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoIntegrationTest.kt new file mode 100644 index 00000000..a48d31e8 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoIntegrationTest.kt @@ -0,0 +1,44 @@ +package com.stevesoltys.backup.crypto + +import com.stevesoltys.backup.header.HeaderReaderImpl +import com.stevesoltys.backup.header.HeaderWriterImpl +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +@TestInstance(PER_METHOD) +class CryptoIntegrationTest { + + private val keyManager = KeyManagerTestImpl() + private val cipherFactory = CipherFactoryImpl(keyManager) + private val headerWriter = HeaderWriterImpl() + private val headerReader = HeaderReaderImpl() + + private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader) + + private val cleartext = byteArrayOf(0x01, 0x02, 0x03) + + private val outputStream = ByteArrayOutputStream() + + @Test + fun `the plain crypto works`() { + val eCipher = cipherFactory.createEncryptionCipher() + val encrypted = eCipher.doFinal(cleartext) + + val dCipher = cipherFactory.createDecryptionCipher(eCipher.iv) + val decrypted = dCipher.doFinal(encrypted) + + assertArrayEquals(cleartext, decrypted) + } + + @Test + fun `encrypted cleartext gets decrypted as expected`() { + crypto.encryptSegment(outputStream, cleartext) + val inputStream = ByteArrayInputStream(outputStream.toByteArray()) + assertArrayEquals(cleartext, crypto.decryptSegment(inputStream)) + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/CryptoTest.kt b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoTest.kt new file mode 100644 index 00000000..e1d129dd --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/crypto/CryptoTest.kt @@ -0,0 +1,192 @@ +package com.stevesoltys.backup.crypto + +import com.stevesoltys.backup.assertContains +import com.stevesoltys.backup.getRandomByteArray +import com.stevesoltys.backup.getRandomString +import com.stevesoltys.backup.header.* +import io.mockk.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD +import java.io.* +import javax.crypto.Cipher +import kotlin.random.Random + +@TestInstance(PER_METHOD) +class CryptoTest { + + private val cipherFactory = mockk() + private val headerWriter = mockk() + private val headerReader = mockk() + + private val crypto = CryptoImpl(cipherFactory, headerWriter, headerReader) + + private val cipher = mockk() + + private val iv = getRandomByteArray(IV_SIZE) + private val cleartext = getRandomByteArray(Random.nextInt(MAX_SEGMENT_LENGTH)) + private val ciphertext = getRandomByteArray(Random.nextInt(MAX_SEGMENT_LENGTH)) + private val versionHeader = VersionHeader(VERSION, getRandomString(MAX_PACKAGE_LENGTH_SIZE), getRandomString(MAX_KEY_LENGTH_SIZE)) + private val versionCiphertext = getRandomByteArray(MAX_VERSION_HEADER_SIZE) + private val versionSegmentHeader = SegmentHeader(versionCiphertext.size.toShort(), iv) + private val outputStream = ByteArrayOutputStream() + private val segmentHeader = SegmentHeader(ciphertext.size.toShort(), iv) + // the headerReader will not actually read the header, so only insert cipher text + private val inputStream = ByteArrayInputStream(ciphertext) + private val versionInputStream = ByteArrayInputStream(versionCiphertext) + + // encrypting + + @Test + fun `encrypt header works as expected`() { + val segmentHeader = CapturingSlot() + every { headerWriter.getEncodedVersionHeader(versionHeader) } returns ciphertext + encryptSegmentHeader(ciphertext, segmentHeader) + + crypto.encryptHeader(outputStream, versionHeader) + assertArrayEquals(iv, segmentHeader.captured.nonce) + assertEquals(ciphertext.size, segmentHeader.captured.segmentLength.toInt()) + } + + @Test + fun `encrypting segment works as expected`() { + val segmentHeader = CapturingSlot() + encryptSegmentHeader(cleartext, segmentHeader) + + crypto.encryptSegment(outputStream, cleartext) + + assertArrayEquals(ciphertext, outputStream.toByteArray()) + assertArrayEquals(iv, segmentHeader.captured.nonce) + assertEquals(ciphertext.size, segmentHeader.captured.segmentLength.toInt()) + } + + private fun encryptSegmentHeader(toEncrypt: ByteArray, segmentHeader: CapturingSlot) { + every { cipherFactory.createEncryptionCipher() } returns cipher + every { cipher.getOutputSize(toEncrypt.size) } returns toEncrypt.size + every { cipher.iv } returns iv + every { headerWriter.writeSegmentHeader(outputStream, capture(segmentHeader)) } just Runs + every { cipher.doFinal(toEncrypt) } returns ciphertext + } + + // decrypting + + @Test + fun `decrypting header works as expected`() { + every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader + every { cipherFactory.createDecryptionCipher(iv) } returns cipher + every { cipher.doFinal(versionCiphertext) } returns cleartext + every { headerReader.getVersionHeader(cleartext) } returns versionHeader + + assertEquals( + versionHeader, + crypto.decryptHeader(versionInputStream, versionHeader.version, versionHeader.packageName, versionHeader.key) + ) + } + + @Test + fun `decrypting header throws if too large`() { + val size = MAX_VERSION_HEADER_SIZE + 1 + val versionCiphertext = getRandomByteArray(size) + val versionInputStream = ByteArrayInputStream(versionCiphertext) + val versionSegmentHeader = SegmentHeader(size.toShort(), iv) + + every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader + + val e = assertThrows(SecurityException::class.java) { + crypto.decryptHeader(versionInputStream, versionHeader.version, versionHeader.packageName, versionHeader.key) + } + assertContains(e.message, size.toString()) + } + + @Test + fun `decrypting header throws because of different version`() { + every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader + every { cipherFactory.createDecryptionCipher(iv) } returns cipher + every { cipher.doFinal(versionCiphertext) } returns cleartext + every { headerReader.getVersionHeader(cleartext) } returns versionHeader + + val version = (VERSION + 1).toByte() + val e = assertThrows(SecurityException::class.java) { + crypto.decryptHeader(versionInputStream, version, versionHeader.packageName, versionHeader.key) + } + assertContains(e.message, version.toString()) + } + + @Test + fun `decrypting header throws because of different package name`() { + every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader + every { cipherFactory.createDecryptionCipher(iv) } returns cipher + every { cipher.doFinal(versionCiphertext) } returns cleartext + every { headerReader.getVersionHeader(cleartext) } returns versionHeader + + val packageName = getRandomString(MAX_PACKAGE_LENGTH_SIZE) + val e = assertThrows(SecurityException::class.java) { + crypto.decryptHeader(versionInputStream, versionHeader.version, packageName, versionHeader.key) + } + assertContains(e.message, packageName) + } + + @Test + fun `decrypting header throws because of different key`() { + every { headerReader.readSegmentHeader(versionInputStream) } returns versionSegmentHeader + every { cipherFactory.createDecryptionCipher(iv) } returns cipher + every { cipher.doFinal(versionCiphertext) } returns cleartext + every { headerReader.getVersionHeader(cleartext) } returns versionHeader + + val e = assertThrows(SecurityException::class.java) { + crypto.decryptHeader(versionInputStream, versionHeader.version, versionHeader.packageName, null) + } + assertContains(e.message, "null") + assertContains(e.message, versionHeader.key ?: fail()) + } + + @Test + fun `decrypting data segment header works as expected`() { + every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader + every { cipherFactory.createDecryptionCipher(iv) } returns cipher + every { cipher.doFinal(ciphertext) } returns cleartext + + assertArrayEquals(cleartext, crypto.decryptSegment(inputStream)) + } + + @Test + fun `decrypting data segment throws if reading 0 bytes`() { + val inputStream = mockk() + val buffer = ByteArray(segmentHeader.segmentLength.toInt()) + + every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader + every { inputStream.read(buffer) } returns 0 + + assertThrows(IOException::class.java) { + crypto.decryptSegment(inputStream) + } + } + + @Test + fun `decrypting data segment throws if reaching end of stream`() { + val inputStream = mockk() + val buffer = ByteArray(segmentHeader.segmentLength.toInt()) + + every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader + every { inputStream.read(buffer) } returns -1 + + assertThrows(EOFException::class.java) { + crypto.decryptSegment(inputStream) + } + } + + @Test + fun `decrypting data segment throws if reading less than expected`() { + val inputStream = mockk() + val buffer = ByteArray(segmentHeader.segmentLength.toInt()) + + every { headerReader.readSegmentHeader(inputStream) } returns segmentHeader + every { inputStream.read(buffer) } returns buffer.size - 1 + + assertThrows(IOException::class.java) { + crypto.decryptSegment(inputStream) + } + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt b/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt new file mode 100644 index 00000000..e9473c89 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt @@ -0,0 +1,26 @@ +package com.stevesoltys.backup.crypto + +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +class KeyManagerTestImpl : KeyManager { + + private val key by lazy { + val keyGenerator = KeyGenerator.getInstance("AES") + keyGenerator.init(KEY_SIZE) + keyGenerator.generateKey() + } + + override fun storeBackupKey(seed: ByteArray) { + TODO("not implemented") + } + + override fun hasBackupKey(): Boolean { + return true + } + + override fun getBackupKey(): SecretKey { + return key + } + +} \ No newline at end of file diff --git a/app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt b/app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt new file mode 100644 index 00000000..94b85b6f --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt @@ -0,0 +1,274 @@ +package com.stevesoltys.backup.header + +import com.stevesoltys.backup.assertContains +import com.stevesoltys.backup.getRandomString +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.ByteBuffer +import kotlin.random.Random + +@TestInstance(PER_CLASS) +internal class HeaderReaderTest { + + private val reader = HeaderReaderImpl() + + // Version Tests + + @Test + fun `valid version is read`() { + val input = byteArrayOf(VERSION) + val inputStream = ByteArrayInputStream(input) + + assertEquals(VERSION, reader.readVersion(inputStream)) + } + + @Test + fun `too short version stream throws exception`() { + val input = ByteArray(0) + val inputStream = ByteArrayInputStream(input) + assertThrows(IOException::class.javaObjectType) { + reader.readVersion(inputStream) + } + } + + @Test + fun `unsupported version throws exception`() { + val input = byteArrayOf((VERSION + 1).toByte()) + val inputStream = ByteArrayInputStream(input) + assertThrows(UnsupportedVersionException::class.javaObjectType) { + reader.readVersion(inputStream) + } + } + + @Test + fun `negative version throws exception`() { + val input = byteArrayOf((-1).toByte()) + val inputStream = ByteArrayInputStream(input) + assertThrows(IOException::class.javaObjectType) { + reader.readVersion(inputStream) + } + } + + @Test + fun `max version byte throws exception`() { + val input = byteArrayOf(Byte.MAX_VALUE) + val inputStream = ByteArrayInputStream(input) + assertThrows(UnsupportedVersionException::class.javaObjectType) { + reader.readVersion(inputStream) + } + } + + // VersionHeader Tests + + @Test + fun `valid VersionHeader is read`() { + val input = byteArrayOf(VERSION, 0x00, 0x01, 0x61, 0x00, 0x01, 0x62) + + val versionHeader = VersionHeader(VERSION, "a", "b") + assertEquals(versionHeader, reader.getVersionHeader(input)) + } + + @Test + fun `zero package length in VersionHeader throws`() { + val input = byteArrayOf(VERSION, 0x00, 0x00, 0x00, 0x01, 0x62) + + assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + } + + @Test + fun `negative package length in VersionHeader throws`() { + val input = byteArrayOf(0x00, 0xFF, 0xFF, 0x00, 0x01, 0x62) + + assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + } + + @Test + fun `too large package length in VersionHeader throws`() { + val size = MAX_PACKAGE_LENGTH_SIZE + 1 + val input = ByteBuffer.allocate(3 + size) + .put(VERSION) + .putShort(size.toShort()) + .put(ByteArray(size)) + .array() + val e = assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + assertContains(e.message, size.toString()) + } + + @Test + fun `insufficient bytes for package in VersionHeader throws`() { + val input = byteArrayOf(VERSION, 0x00, 0x50) + + assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + } + + @Test + fun `zero key length in VersionHeader gets accepted`() { + val input = byteArrayOf(VERSION, 0x00, 0x01, 0x61, 0x00, 0x00) + + val versionHeader = VersionHeader(VERSION, "a", null) + assertEquals(versionHeader, reader.getVersionHeader(input)) + } + + @Test + fun `negative key length in VersionHeader throws`() { + val input = byteArrayOf(0x00, 0x00, 0x01, 0x61, 0xFF, 0xFF) + + assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + } + + @Test + fun `too large key length in VersionHeader throws`() { + val size = MAX_KEY_LENGTH_SIZE + 1 + val input = ByteBuffer.allocate(4 + size) + .put(VERSION) + .putShort(1.toShort()) + .put("a".toByteArray(Utf8)) + .putShort(size.toShort()) + .array() + val e = assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + assertContains(e.message, size.toString()) + } + + @Test + fun `insufficient bytes for key in VersionHeader throws`() { + val input = byteArrayOf(0x00, 0x00, 0x01, 0x61, 0x00, 0x50) + + assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + } + + @Test + fun `extra bytes in VersionHeader throws`() { + val input = byteArrayOf(VERSION, 0x00, 0x01, 0x61, 0x00, 0x01, 0x62, 0x00) + + assertThrows(SecurityException::class.javaObjectType) { + reader.getVersionHeader(input) + } + } + + @Test + fun `max sized VersionHeader gets accepted`() { + val packageName = getRandomString(MAX_PACKAGE_LENGTH_SIZE) + val key = getRandomString(MAX_KEY_LENGTH_SIZE) + val input = ByteBuffer.allocate(MAX_VERSION_HEADER_SIZE) + .put(VERSION) + .putShort(MAX_PACKAGE_LENGTH_SIZE.toShort()) + .put(packageName.toByteArray(Utf8)) + .putShort(MAX_KEY_LENGTH_SIZE.toShort()) + .put(key.toByteArray(Utf8)) + .array() + assertEquals(MAX_VERSION_HEADER_SIZE, input.size) + val h = reader.getVersionHeader(input) + assertEquals(VERSION, h.version) + assertEquals(packageName, h.packageName) + assertEquals(key, h.key) + } + + // SegmentHeader Tests + + @Test + fun `too short SegmentHeader throws exception`() { + val input = byteArrayOf(0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val inputStream = ByteArrayInputStream(input) + assertThrows(IOException::class.javaObjectType) { + reader.readSegmentHeader(inputStream) + } + } + + @Test + fun `segment length of zero is rejected`() { + val input = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val inputStream = ByteArrayInputStream(input) + assertThrows(IOException::class.javaObjectType) { + reader.readSegmentHeader(inputStream) + } + } + + @Test + fun `negative segment length is rejected`() { + val input = byteArrayOf(0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val inputStream = ByteArrayInputStream(input) + assertThrows(IOException::class.javaObjectType) { + reader.readSegmentHeader(inputStream) + } + } + + @Test + fun `minimum negative segment length is rejected`() { + val input = byteArrayOf(0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val inputStream = ByteArrayInputStream(input) + assertThrows(IOException::class.javaObjectType) { + reader.readSegmentHeader(inputStream) + } + } + + @Test + fun `max segment length is accepted`() { + val input = byteArrayOf(0x7F, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val inputStream = ByteArrayInputStream(input) + assertEquals(MAX_SEGMENT_LENGTH, reader.readSegmentHeader(inputStream).segmentLength.toInt()) + } + + @Test + fun `min segment length of 1 is accepted`() { + val input = byteArrayOf(0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + val inputStream = ByteArrayInputStream(input) + assertEquals(1, reader.readSegmentHeader(inputStream).segmentLength.toInt()) + } + + @Test + fun `segment length is always read correctly`() { + val segmentLength = getRandomValidSegmentLength() + val input = ByteBuffer.allocate(SEGMENT_HEADER_SIZE) + .putShort(segmentLength) + .put(ByteArray(IV_SIZE)) + .array() + val inputStream = ByteArrayInputStream(input) + assertEquals(segmentLength, reader.readSegmentHeader(inputStream).segmentLength) + } + + @Test + fun `nonce is read in big endian`() { + val nonce = byteArrayOf(0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01) + val input = byteArrayOf(0x00, 0x01, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01) + val inputStream = ByteArrayInputStream(input) + assertArrayEquals(nonce, reader.readSegmentHeader(inputStream).nonce) + } + + @Test + fun `nonce is always read correctly`() { + val nonce = ByteArray(IV_SIZE).apply { Random.nextBytes(this) } + val input = ByteBuffer.allocate(SEGMENT_HEADER_SIZE) + .putShort(1) + .put(nonce) + .array() + val inputStream = ByteArrayInputStream(input) + assertArrayEquals(nonce, reader.readSegmentHeader(inputStream).nonce) + } + + private fun byteArrayOf(vararg elements: Int): ByteArray { + return elements.map { it.toByte() }.toByteArray() + } + +} + +internal fun getRandomValidSegmentLength(): Short { + return Random.nextInt(1, Short.MAX_VALUE.toInt()).toShort() +} diff --git a/app/src/test/java/com/stevesoltys/backup/header/HeaderWriterReaderTest.kt b/app/src/test/java/com/stevesoltys/backup/header/HeaderWriterReaderTest.kt new file mode 100644 index 00000000..a1df050b --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/header/HeaderWriterReaderTest.kt @@ -0,0 +1,102 @@ +package com.stevesoltys.backup.header + +import com.stevesoltys.backup.getRandomByteArray +import com.stevesoltys.backup.getRandomString +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import kotlin.random.Random + +@TestInstance(PER_CLASS) +internal class HeaderWriterReaderTest { + + private val writer = HeaderWriterImpl() + private val reader = HeaderReaderImpl() + + private val packageName = getRandomString(MAX_PACKAGE_LENGTH_SIZE) + private val key = getRandomString(MAX_KEY_LENGTH_SIZE) + private val versionHeader = VersionHeader(VERSION, packageName, key) + private val unsupportedVersionHeader = VersionHeader((VERSION + 1).toByte(), packageName) + + private val segmentLength = getRandomValidSegmentLength() + private val nonce = getRandomByteArray(IV_SIZE) + private val segmentHeader = SegmentHeader(segmentLength, nonce) + + @Test + fun `written version matches read input`() { + assertEquals(versionHeader.version, readWriteVersion(versionHeader)) + } + + @Test + fun `reading unsupported version throws exception`() { + assertThrows(UnsupportedVersionException::class.javaObjectType) { + readWriteVersion(unsupportedVersionHeader) + } + } + + @Test + fun `VersionHeader output matches read input`() { + assertEquals(versionHeader, readWrite(versionHeader)) + } + + @Test + fun `VersionHeader with no key output matches read input`() { + val versionHeader = VersionHeader(VERSION, packageName, null) + assertEquals(versionHeader, readWrite(versionHeader)) + } + + @Test + fun `VersionHeader with empty package name throws`() { + val versionHeader = VersionHeader(VERSION, "") + assertThrows(SecurityException::class.java) { + readWrite(versionHeader) + } + } + + @Test + fun `SegmentHeader constructor needs right IV size`() { + val nonceTooBig = ByteArray(IV_SIZE + 1).apply { Random.nextBytes(this) } + assertThrows(IllegalStateException::class.javaObjectType) { + SegmentHeader(segmentLength, nonceTooBig) + } + val nonceTooSmall = ByteArray(IV_SIZE - 1).apply { Random.nextBytes(this) } + assertThrows(IllegalStateException::class.javaObjectType) { + SegmentHeader(segmentLength, nonceTooSmall) + } + } + + @Test + fun `SegmentHeader output matches read input`() { + assertEquals(segmentHeader, readWriteVersion(segmentHeader)) + } + + private fun readWriteVersion(header: VersionHeader): Byte { + val outputStream = ByteArrayOutputStream() + writer.writeVersion(outputStream, header) + val written = outputStream.toByteArray() + val inputStream = ByteArrayInputStream(written) + return reader.readVersion(inputStream) + } + + private fun readWrite(header: VersionHeader): VersionHeader { + val written = writer.getEncodedVersionHeader(header) + return reader.getVersionHeader(written) + } + + private fun readWriteVersion(header: SegmentHeader): SegmentHeader { + val outputStream = ByteArrayOutputStream() + writer.writeSegmentHeader(outputStream, header) + val written = outputStream.toByteArray() + val inputStream = ByteArrayInputStream(written) + return reader.readSegmentHeader(inputStream) + } + + private fun assertEquals(expected: SegmentHeader, actual: SegmentHeader) { + assertEquals(expected.segmentLength, actual.segmentLength) + assertArrayEquals(expected.nonce, actual.nonce) + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt new file mode 100644 index 00000000..9622568c --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt @@ -0,0 +1,162 @@ +package com.stevesoltys.backup.transport + +import android.app.backup.BackupDataInput +import android.app.backup.BackupDataOutput +import android.app.backup.BackupTransport.NO_MORE_DATA +import android.app.backup.BackupTransport.TRANSPORT_OK +import android.app.backup.RestoreDescription +import android.app.backup.RestoreDescription.TYPE_FULL_STREAM +import android.os.ParcelFileDescriptor +import com.stevesoltys.backup.crypto.CipherFactoryImpl +import com.stevesoltys.backup.crypto.CryptoImpl +import com.stevesoltys.backup.crypto.KeyManagerTestImpl +import com.stevesoltys.backup.transport.backup.* +import com.stevesoltys.backup.header.HeaderReaderImpl +import com.stevesoltys.backup.header.HeaderWriterImpl +import com.stevesoltys.backup.header.Utf8 +import com.stevesoltys.backup.transport.restore.* +import io.mockk.* +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.* +import kotlin.random.Random + +internal class CoordinatorIntegrationTest : TransportTest() { + + private val inputFactory = mockk() + private val outputFactory = mockk() + private val keyManager = KeyManagerTestImpl() + private val cipherFactory = CipherFactoryImpl(keyManager) + private val headerWriter = HeaderWriterImpl() + private val headerReader = HeaderReaderImpl() + private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader) + + private val backupPlugin = mockk() + private val kvBackupPlugin = mockk() + private val kvBackup = KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl) + private val fullBackupPlugin = mockk() + private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl) + private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup) + + private val restorePlugin = mockk() + private val kvRestorePlugin = mockk() + private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl) + private val fullRestorePlugin = mockk() + private val fullRestore = FullRestore(fullRestorePlugin, outputFactory, headerReader, cryptoImpl) + private val restore = RestoreCoordinator(restorePlugin, kvRestore, fullRestore) + + private val backupDataInput = mockk() + private val fileDescriptor = mockk(relaxed = true) + private val token = DEFAULT_RESTORE_SET_TOKEN + private val appData = ByteArray(42).apply { Random.nextBytes(this) } + private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) } + private val key = "RestoreKey" + private val key64 = Base64.getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8)) + private val key2 = "RestoreKey2" + private val key264 = Base64.getUrlEncoder().withoutPadding().encodeToString(key2.toByteArray(Utf8)) + + init { + every { backupPlugin.kvBackupPlugin } returns kvBackupPlugin + every { backupPlugin.fullBackupPlugin } returns fullBackupPlugin + } + + @Test + fun `test key-value backup and restore with 2 records`() { + val value = CapturingSlot() + val value2 = CapturingSlot() + val bOutputStream = ByteArrayOutputStream() + val bOutputStream2 = ByteArrayOutputStream() + + // read one key/value record and write it to output stream + every { kvBackupPlugin.hasDataForPackage(packageInfo) } returns false + every { kvBackupPlugin.ensureRecordStorageForPackage(packageInfo) } just Runs + every { inputFactory.getBackupDataInput(fileDescriptor) } returns backupDataInput + every { backupDataInput.readNextHeader() } returns true andThen true andThen false + every { backupDataInput.key } returns key andThen key2 + every { backupDataInput.dataSize } returns appData.size andThen appData2.size + every { backupDataInput.readEntityData(capture(value), 0, appData.size) } answers { + appData.copyInto(value.captured) // write the app data into the passed ByteArray + appData.size + } + every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key64) } returns bOutputStream + every { backupDataInput.readEntityData(capture(value2), 0, appData2.size) } answers { + appData2.copyInto(value2.captured) // write the app data into the passed ByteArray + appData2.size + } + every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key264) } returns bOutputStream2 + + // start and finish K/V backup + assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0)) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + + // start restore + assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo))) + + // find data for K/V backup + every { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns true + + val restoreDescription = restore.nextRestorePackage() ?: fail() + assertEquals(packageInfo.packageName, restoreDescription.packageName) + assertEquals(RestoreDescription.TYPE_KEY_VALUE, restoreDescription.dataType) + + // restore finds the backed up key and writes the decrypted value + val backupDataOutput = mockk() + val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) + val rInputStream2 = ByteArrayInputStream(bOutputStream2.toByteArray()) + every { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64, key264) + every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput + every { kvRestorePlugin.getInputStreamForRecord(token, packageInfo, key64) } returns rInputStream + every { backupDataOutput.writeEntityHeader(key, appData.size) } returns 1137 + every { backupDataOutput.writeEntityData(appData, appData.size) } returns appData.size + every { kvRestorePlugin.getInputStreamForRecord(token, packageInfo, key264) } returns rInputStream2 + every { backupDataOutput.writeEntityHeader(key2, appData2.size) } returns 1137 + every { backupDataOutput.writeEntityData(appData2, appData2.size) } returns appData2.size + + assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) + } + + @Test + fun `test full backup and restore with two chunks`() { + // return streams from plugin and app data + val bOutputStream = ByteArrayOutputStream() + val bInputStream = ByteArrayInputStream(appData) + every { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream + every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream + every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP + + // perform backup to output stream + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0)) + assertEquals(TRANSPORT_OK, backup.sendBackupData(appData.size / 2)) + assertEquals(TRANSPORT_OK, backup.sendBackupData(appData.size / 2)) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + + // start restore + assertEquals(TRANSPORT_OK, restore.startRestore(token, arrayOf(packageInfo))) + + // find data only for full backup + every { kvRestorePlugin.hasDataForPackage(token, packageInfo) } returns false + every { fullRestorePlugin.hasDataForPackage(token, packageInfo) } returns true + + val restoreDescription = restore.nextRestorePackage() ?: fail() + assertEquals(packageInfo.packageName, restoreDescription.packageName) + assertEquals(TYPE_FULL_STREAM, restoreDescription.dataType) + + // reverse the backup streams into restore input + val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) + val rOutputStream = ByteArrayOutputStream() + every { fullRestorePlugin.getInputStreamForPackage(token, packageInfo) } returns rInputStream + every { outputFactory.getOutputStream(fileDescriptor) } returns rOutputStream + + // restore data + assertEquals(appData.size / 2, restore.getNextFullRestoreDataChunk(fileDescriptor)) + assertEquals(appData.size / 2, restore.getNextFullRestoreDataChunk(fileDescriptor)) + assertEquals(NO_MORE_DATA, restore.getNextFullRestoreDataChunk(fileDescriptor)) + restore.finishRestore() + + // assert that restored data matches original app data + assertArrayEquals(appData, rOutputStream.toByteArray()) + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt new file mode 100644 index 00000000..6c425339 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/TransportTest.kt @@ -0,0 +1,30 @@ +package com.stevesoltys.backup.transport + +import android.content.pm.PackageInfo +import android.util.Log +import com.stevesoltys.backup.crypto.Crypto +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD + +@TestInstance(PER_METHOD) +abstract class TransportTest { + + protected val crypto = mockk() + + protected val packageInfo = PackageInfo().apply { packageName = "org.example" } + + init { + mockkStatic(Log::class) + every { Log.v(any(), any()) } returns 0 + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), ofType(String::class)) } returns 0 + every { Log.w(any(), ofType(String::class), any()) } returns 0 + every { Log.e(any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt new file mode 100644 index 00000000..e5833277 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt @@ -0,0 +1,110 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupTransport.TRANSPORT_ERROR +import android.app.backup.BackupTransport.TRANSPORT_OK +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.io.IOException +import kotlin.random.Random + +internal class BackupCoordinatorTest: BackupTest() { + + private val plugin = mockk() + private val kv = mockk() + private val full = mockk() + + private val backup = BackupCoordinator(plugin, kv, full) + + @Test + fun `device initialization succeeds and delegates to plugin`() { + every { plugin.initializeDevice() } just Runs + every { kv.hasState() } returns false + every { full.hasState() } returns false + + assertEquals(TRANSPORT_OK, backup.initializeDevice()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + } + + @Test + fun `device initialization fails`() { + every { plugin.initializeDevice() } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) + + // finish will only be called when TRANSPORT_OK is returned, so it should throw + every { kv.hasState() } returns false + every { full.hasState() } returns false + assertThrows(IllegalStateException::class.java) { + backup.finishBackup() + } + } + + @Test + fun `getBackupQuota() delegates to right plugin`() { + val isFullBackup = Random.nextBoolean() + val quota = Random.nextLong() + + if (isFullBackup) { + every { full.getQuota() } returns quota + } else { + every { kv.getQuota() } returns quota + } + assertEquals(quota, backup.getBackupQuota(packageInfo.packageName, isFullBackup)) + } + + @Test + fun `clearing KV backup data throws`() { + every { kv.clearBackupData(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo)) + } + + @Test + fun `clearing full backup data throws`() { + every { kv.clearBackupData(packageInfo) } just Runs + every { full.clearBackupData(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.clearBackupData(packageInfo)) + } + + @Test + fun `clearing backup data succeeds`() { + every { kv.clearBackupData(packageInfo) } just Runs + every { full.clearBackupData(packageInfo) } just Runs + + assertEquals(TRANSPORT_OK, backup.clearBackupData(packageInfo)) + + every { kv.hasState() } returns false + every { full.hasState() } returns false + + assertEquals(TRANSPORT_OK, backup.finishBackup()) + } + + @Test + fun `finish backup delegates to KV plugin if it has state`() { + val result = Random.nextInt() + + every { kv.hasState() } returns true + every { full.hasState() } returns false + every { kv.finishBackup() } returns result + + assertEquals(result, backup.finishBackup()) + } + + @Test + fun `finish backup delegates to full plugin if it has state`() { + val result = Random.nextInt() + + every { kv.hasState() } returns false + every { full.hasState() } returns true + every { full.finishBackup() } returns result + + assertEquals(result, backup.finishBackup()) + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt new file mode 100644 index 00000000..df96b38e --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupTest.kt @@ -0,0 +1,20 @@ +package com.stevesoltys.backup.transport.backup + +import android.os.ParcelFileDescriptor +import com.stevesoltys.backup.transport.TransportTest +import com.stevesoltys.backup.header.HeaderWriter +import com.stevesoltys.backup.header.VersionHeader +import io.mockk.mockk +import java.io.OutputStream + +internal abstract class BackupTest : TransportTest() { + + protected val inputFactory = mockk() + protected val headerWriter = mockk() + protected val data = mockk() + protected val outputStream = mockk() + + protected val header = VersionHeader(packageName = packageInfo.packageName) + protected val quota = 42L + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt new file mode 100644 index 00000000..b6f1972e --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt @@ -0,0 +1,276 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupTransport.* +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.FileInputStream +import java.io.IOException +import kotlin.random.Random + +internal class FullBackupTest : BackupTest() { + + private val plugin = mockk() + private val backup = FullBackup(plugin, inputFactory, headerWriter, crypto) + + private val bytes = ByteArray(23).apply { Random.nextBytes(this) } + private val closeBytes = ByteArray(42).apply { Random.nextBytes(this) } + private val inputStream = mockk() + + @Test + fun `now is a good time for a backup`() { + assertEquals(0, backup.requestFullBackupTime()) + } + + @Test + fun `has no initial state`() { + assertFalse(backup.hasState()) + } + + @Test + fun `checkFullBackupSize exceeds quota`() { + every { plugin.getQuota() } returns quota + + assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.checkFullBackupSize(quota + 1)) + } + + @Test + fun `checkFullBackupSize for no data`() { + assertEquals(TRANSPORT_PACKAGE_REJECTED, backup.checkFullBackupSize(0)) + } + + @Test + fun `checkFullBackupSize for negative data`() { + assertEquals(TRANSPORT_PACKAGE_REJECTED, backup.checkFullBackupSize(-1)) + } + + @Test + fun `checkFullBackupSize accepts min data`() { + every { plugin.getQuota() } returns quota + + assertEquals(TRANSPORT_OK, backup.checkFullBackupSize(1)) + } + + @Test + fun `checkFullBackupSize accepts max data`() { + every { plugin.getQuota() } returns quota + + assertEquals(TRANSPORT_OK, backup.checkFullBackupSize(quota)) + } + + @Test + fun `performFullBackup throws exception when getting outputStream`() { + every { plugin.getOutputStream(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performFullBackup(packageInfo, data)) + assertFalse(backup.hasState()) + } + + @Test + fun `performFullBackup throws exception when writing header`() { + every { plugin.getOutputStream(packageInfo) } returns outputStream + every { inputFactory.getInputStream(data) } returns inputStream + every { headerWriter.writeVersion(outputStream, header) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performFullBackup(packageInfo, data)) + assertFalse(backup.hasState()) + } + + @Test + fun `performFullBackup runs ok`() { + expectPerformFullBackup() + expectClearState() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `sendBackupData first call over quota`() { + expectPerformFullBackup() + val numBytes = (quota + 1).toInt() + expectSendData(numBytes) + expectClearState() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.sendBackupData(numBytes)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `sendBackupData second call over quota`() { + expectPerformFullBackup() + val numBytes1 = quota.toInt() + expectSendData(numBytes1) + val numBytes2 = 1 + expectSendData(numBytes2) + expectClearState() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes1)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_QUOTA_EXCEEDED, backup.sendBackupData(numBytes2)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `sendBackupData throws exception when reading from InputStream`() { + expectPerformFullBackup() + every { plugin.getQuota() } returns quota + every { inputStream.read(any(), any(), bytes.size) } throws IOException() + expectClearState() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `sendBackupData throws exception when writing encrypted data to OutputStream`() { + expectPerformFullBackup() + every { plugin.getQuota() } returns quota + every { inputStream.read(any(), any(), bytes.size) } returns bytes.size + every { crypto.encryptSegment(outputStream, any()) } throws IOException() + expectClearState() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_ERROR, backup.sendBackupData(bytes.size)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `sendBackupData runs ok`() { + expectPerformFullBackup() + val numBytes1 = (quota / 2).toInt() + expectSendData(numBytes1) + val numBytes2 = (quota / 2).toInt() + expectSendData(numBytes2) + expectClearState() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes1)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.sendBackupData(numBytes2)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `cancel full backup runs ok`() { + expectPerformFullBackup() + expectClearState() + every { plugin.cancelFullBackup(packageInfo) } just Runs + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + backup.cancelFullBackup() + assertFalse(backup.hasState()) + } + + @Test + fun `cancel full backup ignores exception when calling plugin`() { + expectPerformFullBackup() + expectClearState() + every { plugin.cancelFullBackup(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + backup.cancelFullBackup() + assertFalse(backup.hasState()) + } + + @Test + fun `clearState throws exception when flushing OutputStream`() { + expectPerformFullBackup() + every { outputStream.write(closeBytes) } just Runs + every { outputStream.flush() } throws IOException() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_ERROR, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `clearState ignores exception when closing OutputStream`() { + expectPerformFullBackup() + every { outputStream.flush() } just Runs + every { outputStream.close() } throws IOException() + every { inputStream.close() } just Runs + every { data.close() } just Runs + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `clearState ignores exception when closing InputStream`() { + expectPerformFullBackup() + every { outputStream.flush() } just Runs + every { outputStream.close() } just Runs + every { inputStream.close() } throws IOException() + every { data.close() } just Runs + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `clearState ignores exception when closing ParcelFileDescriptor`() { + expectPerformFullBackup() + every { outputStream.flush() } just Runs + every { outputStream.close() } just Runs + every { inputStream.close() } just Runs + every { data.close() } throws IOException() + + assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + private fun expectPerformFullBackup() { + every { plugin.getOutputStream(packageInfo) } returns outputStream + every { inputFactory.getInputStream(data) } returns inputStream + every { headerWriter.writeVersion(outputStream, header) } just Runs + every { crypto.encryptHeader(outputStream, header) } just Runs + } + + private fun expectSendData(numBytes: Int, readBytes: Int = numBytes) { + every { plugin.getQuota() } returns quota + every { inputStream.read(any(), any(), numBytes) } returns readBytes + every { crypto.encryptSegment(outputStream, any()) } just Runs + } + + private fun expectClearState() { + every { outputStream.write(closeBytes) } just Runs + every { outputStream.flush() } just Runs + every { outputStream.close() } just Runs + every { inputStream.close() } just Runs + every { data.close() } just Runs + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt new file mode 100644 index 00000000..5362415b --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt @@ -0,0 +1,216 @@ +package com.stevesoltys.backup.transport.backup + +import android.app.backup.BackupDataInput +import android.app.backup.BackupTransport.* +import com.stevesoltys.backup.getRandomString +import com.stevesoltys.backup.header.MAX_KEY_LENGTH_SIZE +import com.stevesoltys.backup.header.Utf8 +import com.stevesoltys.backup.header.VersionHeader +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.IOException +import java.util.* +import kotlin.random.Random + +internal class KVBackupTest : BackupTest() { + + private val plugin = mockk() + private val dataInput = mockk() + + private val backup = KVBackup(plugin, inputFactory, headerWriter, crypto) + + private val key = getRandomString(MAX_KEY_LENGTH_SIZE) + private val key64 = Base64.getEncoder().encodeToString(key.toByteArray(Utf8)) + private val value = ByteArray(23).apply { Random.nextBytes(this) } + private val versionHeader = VersionHeader(packageName = packageInfo.packageName, key = key) + + @Test + fun `now is a good time for a backup`() { + assertEquals(0, backup.requestBackupTime()) + } + + @Test + fun `has no initial state`() { + assertFalse(backup.hasState()) + } + + @Test + fun `simple backup with one record`() { + singleRecordBackup() + + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `incremental backup with no data gets rejected`() { + every { plugin.hasDataForPackage(packageInfo) } returns false + + assertEquals(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED, backup.performBackup(packageInfo, data, FLAG_INCREMENTAL)) + assertFalse(backup.hasState()) + } + + @Test + fun `check for existing data throws exception`() { + every { plugin.hasDataForPackage(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `non-incremental backup with data clears old data first`() { + singleRecordBackup(true) + every { plugin.removeDataOfPackage(packageInfo) } just Runs + + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `ignoring exception when clearing data when non-incremental backup has data`() { + singleRecordBackup(true) + every { plugin.removeDataOfPackage(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `ensuring storage throws exception`() { + every { plugin.hasDataForPackage(packageInfo) } returns false + every { plugin.ensureRecordStorageForPackage(packageInfo) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `exception while reading next header`() { + initPlugin(false) + createBackupDataInput() + every { dataInput.readNextHeader() } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `exception while reading value`() { + initPlugin(false) + createBackupDataInput() + every { dataInput.readNextHeader() } returns true + every { dataInput.key } returns key + every { dataInput.dataSize } returns value.size + every { dataInput.readEntityData(any(), 0, value.size) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `no data records`() { + initPlugin(false) + getDataInput(listOf(false)) + + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + @Test + fun `exception while writing version header`() { + initPlugin(false) + getDataInput(listOf(true)) + every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream + every { headerWriter.writeVersion(outputStream, versionHeader) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `exception while writing encrypted value to output stream`() { + initPlugin(false) + getDataInput(listOf(true)) + writeHeaderAndEncrypt() + every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream + every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs + every { crypto.encryptSegment(outputStream, any()) } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `exception while flushing output stream`() { + initPlugin(false) + getDataInput(listOf(true)) + writeHeaderAndEncrypt() + every { outputStream.write(value) } just Runs + every { outputStream.flush() } throws IOException() + + assertEquals(TRANSPORT_ERROR, backup.performBackup(packageInfo, data, 0)) + assertFalse(backup.hasState()) + } + + @Test + fun `ignoring exception while closing output stream`() { + initPlugin(false) + getDataInput(listOf(true, false)) + writeHeaderAndEncrypt() + every { outputStream.write(value) } just Runs + every { outputStream.flush() } just Runs + every { outputStream.close() } throws IOException() + + assertEquals(TRANSPORT_OK, backup.performBackup(packageInfo, data, 0)) + assertTrue(backup.hasState()) + assertEquals(TRANSPORT_OK, backup.finishBackup()) + assertFalse(backup.hasState()) + } + + private fun singleRecordBackup(hasDataForPackage: Boolean = false) { + initPlugin(hasDataForPackage) + getDataInput(listOf(true, false)) + writeHeaderAndEncrypt() + every { outputStream.write(value) } just Runs + every { outputStream.flush() } just Runs + every { outputStream.close() } just Runs + } + + private fun initPlugin(hasDataForPackage: Boolean = false) { + every { plugin.hasDataForPackage(packageInfo) } returns hasDataForPackage + every { plugin.ensureRecordStorageForPackage(packageInfo) } just Runs + } + + private fun createBackupDataInput() { + every { inputFactory.getBackupDataInput(data) } returns dataInput + } + + private fun getDataInput(returnValues: List) { + createBackupDataInput() + every { dataInput.readNextHeader() } returnsMany returnValues + every { dataInput.key } returns key + every { dataInput.dataSize } returns value.size + every { dataInput.readEntityData(any(), 0, value.size) } returns value.size + } + + private fun writeHeaderAndEncrypt() { + every { plugin.getOutputStreamForRecord(packageInfo, key64) } returns outputStream + every { headerWriter.writeVersion(outputStream, versionHeader) } just Runs + every { crypto.encryptHeader(outputStream, versionHeader) } just Runs + every { crypto.encryptSegment(outputStream, any()) } just Runs + } + +} \ No newline at end of file diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/FullRestoreTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/FullRestoreTest.kt new file mode 100644 index 00000000..fd6ee47d --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/FullRestoreTest.kt @@ -0,0 +1,173 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.BackupTransport.* +import com.stevesoltys.backup.getRandomByteArray +import com.stevesoltys.backup.header.UnsupportedVersionException +import com.stevesoltys.backup.header.VERSION +import com.stevesoltys.backup.header.VersionHeader +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.ByteArrayOutputStream +import java.io.EOFException +import java.io.IOException +import kotlin.random.Random + +internal class FullRestoreTest : RestoreTest() { + + private val plugin = mockk() + private val restore = FullRestore(plugin, outputFactory, headerReader, crypto) + + private val encrypted = getRandomByteArray() + private val outputStream = ByteArrayOutputStream() + private val versionHeader = VersionHeader(VERSION, packageInfo.packageName) + + @Test + fun `has no initial state`() { + assertFalse(restore.hasState()) + } + + @Test + fun `hasDataForPackage() delegates to plugin`() { + val result = Random.nextBoolean() + every { plugin.hasDataForPackage(token, packageInfo) } returns result + assertEquals(result, restore.hasDataForPackage(token, packageInfo)) + } + + @Test + fun `initializing state leaves a state`() { + assertFalse(restore.hasState()) + restore.initializeState(token, packageInfo) + assertTrue(restore.hasState()) + } + + @Test + fun `getting chunks without initializing state throws`() { + assertFalse(restore.hasState()) + assertThrows(IllegalStateException::class.java) { + restore.getNextFullRestoreDataChunk(fileDescriptor) + } + } + + @Test + fun `getting InputStream for package when getting first chunk throws`() { + restore.initializeState(token, packageInfo) + + every { plugin.getInputStreamForPackage(token, packageInfo) } throws IOException() + + assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `reading version header when getting first chunk throws`() { + restore.initializeState(token, packageInfo) + + every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + every { headerReader.readVersion(inputStream) } throws IOException() + + assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `reading unsupported version when getting first chunk`() { + restore.initializeState(token, packageInfo) + + every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + every { headerReader.readVersion(inputStream) } throws UnsupportedVersionException(unsupportedVersion) + + assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `decrypting version header when getting first chunk throws`() { + restore.initializeState(token, packageInfo) + + every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } throws IOException() + + assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `decrypting version header when getting first chunk throws security exception`() { + restore.initializeState(token, packageInfo) + + every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } throws SecurityException() + + assertEquals(TRANSPORT_ERROR, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `decrypting segment throws IOException`() { + restore.initializeState(token, packageInfo) + + initInputStream() + every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream + every { crypto.decryptSegment(inputStream) } throws IOException() + every { inputStream.close() } just Runs + every { fileDescriptor.close() } just Runs + + assertEquals(TRANSPORT_PACKAGE_REJECTED, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `decrypting segment throws EOFException`() { + restore.initializeState(token, packageInfo) + + initInputStream() + every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream + every { crypto.decryptSegment(inputStream) } throws EOFException() + every { inputStream.close() } just Runs + every { fileDescriptor.close() } just Runs + + assertEquals(NO_MORE_DATA, restore.getNextFullRestoreDataChunk(fileDescriptor)) + } + + @Test + fun `full chunk gets encrypted`() { + restore.initializeState(token, packageInfo) + + initInputStream() + readAndEncryptInputStream(encrypted) + every { inputStream.close() } just Runs + + assertEquals(encrypted.size, restore.getNextFullRestoreDataChunk(fileDescriptor)) + assertArrayEquals(encrypted, outputStream.toByteArray()) + restore.finishRestore() + assertFalse(restore.hasState()) + } + + @Test + fun `aborting full restore closes stream, resets state`() { + restore.initializeState(token, packageInfo) + + initInputStream() + readAndEncryptInputStream(encrypted) + + restore.getNextFullRestoreDataChunk(fileDescriptor) + + every { inputStream.close() } just Runs + + assertEquals(TRANSPORT_OK, restore.abortFullRestore()) + assertFalse(restore.hasState()) + } + + private fun initInputStream() { + every { plugin.getInputStreamForPackage(token, packageInfo) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName) } returns versionHeader + } + + private fun readAndEncryptInputStream(encryptedBytes: ByteArray) { + every { outputFactory.getOutputStream(fileDescriptor) } returns outputStream + every { crypto.decryptSegment(inputStream) } returns encryptedBytes + every { fileDescriptor.close() } just Runs + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt new file mode 100644 index 00000000..f0237ce8 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt @@ -0,0 +1,221 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.BackupDataOutput +import android.app.backup.BackupTransport.TRANSPORT_ERROR +import android.app.backup.BackupTransport.TRANSPORT_OK +import com.stevesoltys.backup.getRandomByteArray +import com.stevesoltys.backup.header.UnsupportedVersionException +import com.stevesoltys.backup.header.Utf8 +import com.stevesoltys.backup.header.VERSION +import com.stevesoltys.backup.header.VersionHeader +import io.mockk.* +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import java.io.IOException +import java.io.InputStream +import java.util.Base64.getUrlEncoder +import kotlin.random.Random + +internal class KVRestoreTest : RestoreTest() { + + private val plugin = mockk() + private val output = mockk() + private val restore = KVRestore(plugin, outputFactory, headerReader, crypto) + + private val key = "Restore Key" + private val key64 = getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8)) + private val versionHeader = VersionHeader(VERSION, packageInfo.packageName, key) + private val key2 = "Restore Key2" + private val key264 = getUrlEncoder().withoutPadding().encodeToString(key2.toByteArray(Utf8)) + private val versionHeader2 = VersionHeader(VERSION, packageInfo.packageName, key2) + + @Test + fun `hasDataForPackage() delegates to plugin`() { + val result = Random.nextBoolean() + + every { plugin.hasDataForPackage(token, packageInfo) } returns result + + assertEquals(result, restore.hasDataForPackage(token, packageInfo)) + } + + @Test + fun `getRestoreData() throws without initializing state`() { + assertThrows(IllegalStateException::class.java) { + restore.getRestoreData(fileDescriptor) + } + } + + @Test + fun `listing records throws`() { + restore.initializeState(token, packageInfo) + + every { plugin.listRecords(token, packageInfo) } throws IOException() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + } + + @Test + fun `reading VersionHeader with unsupported version throws`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } throws UnsupportedVersionException(unsupportedVersion) + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `error reading VersionHeader throws`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `decrypting segment throws`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader + every { crypto.decryptSegment(inputStream) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `decrypting header throws`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `decrypting header throws security exception`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } throws SecurityException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `writing header throws`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader + every { crypto.decryptSegment(inputStream) } returns data + every { output.writeEntityHeader(key, data.size) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `writing value throws`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader + every { crypto.decryptSegment(inputStream) } returns data + every { output.writeEntityHeader(key, data.size) } returns 42 + every { output.writeEntityData(data, data.size) } throws IOException() + streamsGetClosed() + + assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `writing value succeeds`() { + restore.initializeState(token, packageInfo) + + getRecordsAndOutput() + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader + every { crypto.decryptSegment(inputStream) } returns data + every { output.writeEntityHeader(key, data.size) } returns 42 + every { output.writeEntityData(data, data.size) } returns data.size + streamsGetClosed() + + assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) + verifyStreamWasClosed() + } + + @Test + fun `writing two values succeeds`() { + val data2 = getRandomByteArray() + val inputStream2 = mockk() + restore.initializeState(token, packageInfo) + + getRecordsAndOutput(listOf(key64, key264)) + // first key/value + every { plugin.getInputStreamForRecord(token, packageInfo, key64) } returns inputStream + every { headerReader.readVersion(inputStream) } returns VERSION + every { crypto.decryptHeader(inputStream, VERSION, packageInfo.packageName, key) } returns versionHeader + every { crypto.decryptSegment(inputStream) } returns data + every { output.writeEntityHeader(key, data.size) } returns 42 + every { output.writeEntityData(data, data.size) } returns data.size + // second key/value + every { plugin.getInputStreamForRecord(token, packageInfo, key264) } returns inputStream2 + every { headerReader.readVersion(inputStream2) } returns VERSION + every { crypto.decryptHeader(inputStream2, VERSION, packageInfo.packageName, key2) } returns versionHeader2 + every { crypto.decryptSegment(inputStream2) } returns data2 + every { output.writeEntityHeader(key2, data2.size) } returns 42 + every { output.writeEntityData(data2, data2.size) } returns data2.size + every { inputStream2.close() } just Runs + streamsGetClosed() + + assertEquals(TRANSPORT_OK, restore.getRestoreData(fileDescriptor)) + } + + private fun getRecordsAndOutput(recordKeys: List = listOf(key64)) { + every { plugin.listRecords(token, packageInfo) } returns recordKeys + every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output + } + + private fun streamsGetClosed() { + every { inputStream.close() } just Runs + every { fileDescriptor.close() } just Runs + } + + private fun verifyStreamWasClosed() { + verifyAll { + inputStream.close() + fileDescriptor.close() + } + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt new file mode 100644 index 00000000..10aa6cb0 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreCoordinatorTest.kt @@ -0,0 +1,189 @@ +package com.stevesoltys.backup.transport.restore + +import android.app.backup.BackupTransport.TRANSPORT_OK +import android.app.backup.RestoreDescription +import android.app.backup.RestoreDescription.* +import android.app.backup.RestoreSet +import android.content.pm.PackageInfo +import android.os.ParcelFileDescriptor +import com.stevesoltys.backup.transport.TransportTest +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import java.io.IOException +import kotlin.random.Random + +internal class RestoreCoordinatorTest : TransportTest() { + + private val plugin = mockk() + private val kv = mockk() + private val full = mockk() + + private val restore = RestoreCoordinator(plugin, kv, full) + + private val token = Random.nextLong() + private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" } + private val packageInfoArray = arrayOf(packageInfo) + private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2) + + @Test + fun `getAvailableRestoreSets() delegates to plugin`() { + val restoreSets = Array(1) { RestoreSet() } + + every { plugin.getAvailableRestoreSets() } returns restoreSets + + assertEquals(restoreSets, restore.getAvailableRestoreSets()) + } + + @Test + fun `getCurrentRestoreSet() delegates to plugin`() { + val currentRestoreSet = Random.nextLong() + + every { plugin.getCurrentRestoreSet() } returns currentRestoreSet + + assertEquals(currentRestoreSet, restore.getCurrentRestoreSet()) + } + + @Test + fun `startRestore() returns OK`() { + assertEquals(TRANSPORT_OK, restore.startRestore(token, packageInfoArray)) + } + + @Test + fun `startRestore() can not be called twice`() { + assertEquals(TRANSPORT_OK, restore.startRestore(token, packageInfoArray)) + assertThrows(IllegalStateException::class.javaObjectType) { + restore.startRestore(token, packageInfoArray) + } + } + + @Test + fun `nextRestorePackage() throws without startRestore()`() { + assertThrows(IllegalStateException::class.javaObjectType) { + restore.nextRestorePackage() + } + } + + @Test + fun `nextRestorePackage() returns KV description and takes precedence`() { + restore.startRestore(token, packageInfoArray) + + every { kv.hasDataForPackage(token, packageInfo) } returns true + every { kv.initializeState(token, packageInfo) } just Runs + + val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE) + assertEquals(expected, restore.nextRestorePackage()) + } + + @Test + fun `nextRestorePackage() returns full description if no KV data found`() { + restore.startRestore(token, packageInfoArray) + + every { kv.hasDataForPackage(token, packageInfo) } returns false + every { full.hasDataForPackage(token, packageInfo) } returns true + every { full.initializeState(token, packageInfo) } just Runs + + val expected = RestoreDescription(packageInfo.packageName, TYPE_FULL_STREAM) + assertEquals(expected, restore.nextRestorePackage()) + } + + @Test + fun `nextRestorePackage() returns NO_MORE_PACKAGES if data found`() { + restore.startRestore(token, packageInfoArray) + + every { kv.hasDataForPackage(token, packageInfo) } returns false + every { full.hasDataForPackage(token, packageInfo) } returns false + + assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage()) + } + + @Test + fun `nextRestorePackage() returns all packages from startRestore()`() { + restore.startRestore(token, packageInfoArray2) + + every { kv.hasDataForPackage(token, packageInfo) } returns true + every { kv.initializeState(token, packageInfo) } just Runs + + val expected = RestoreDescription(packageInfo.packageName, TYPE_KEY_VALUE) + assertEquals(expected, restore.nextRestorePackage()) + + every { kv.hasDataForPackage(token, packageInfo2) } returns false + every { full.hasDataForPackage(token, packageInfo2) } returns true + every { full.initializeState(token, packageInfo2) } just Runs + + val expected2 = RestoreDescription(packageInfo2.packageName, TYPE_FULL_STREAM) + assertEquals(expected2, restore.nextRestorePackage()) + + assertEquals(NO_MORE_PACKAGES, restore.nextRestorePackage()) + } + + @Test + fun `when kv#hasDataForPackage() throws return null`() { + restore.startRestore(token, packageInfoArray) + + every { kv.hasDataForPackage(token, packageInfo) } throws IOException() + + assertNull(restore.nextRestorePackage()) + } + + @Test + fun `when full#hasDataForPackage() throws return null`() { + restore.startRestore(token, packageInfoArray) + + every { kv.hasDataForPackage(token, packageInfo) } returns false + every { full.hasDataForPackage(token, packageInfo) } throws IOException() + + assertNull(restore.nextRestorePackage()) + } + + @Test + fun `getRestoreData() delegates to KV`() { + val data = mockk() + val result = Random.nextInt() + + every { kv.getRestoreData(data) } returns result + + assertEquals(result, restore.getRestoreData(data)) + } + + @Test + fun `getNextFullRestoreDataChunk() delegates to Full`() { + val data = mockk() + val result = Random.nextInt() + + every { full.getNextFullRestoreDataChunk(data) } returns result + + assertEquals(result, restore.getNextFullRestoreDataChunk(data)) + } + + @Test + fun `abortFullRestore() delegates to Full`() { + val result = Random.nextInt() + + every { full.abortFullRestore() } returns result + + assertEquals(result, restore.abortFullRestore()) + } + + @Test + fun `finishRestore() delegates to Full if it has state`() { + val hasState = Random.nextBoolean() + + every { full.hasState() } returns hasState + if (hasState) { + every { full.finishRestore() } just Runs + } + + restore.finishRestore() + } + + private fun assertEquals(expected: RestoreDescription, actual: RestoreDescription?) { + assertNotNull(actual) + assertEquals(expected.packageName, actual?.packageName) + assertEquals(expected.dataType, actual?.dataType) + } + +} diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreTest.kt new file mode 100644 index 00000000..577f3a6c --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/RestoreTest.kt @@ -0,0 +1,24 @@ +package com.stevesoltys.backup.transport.restore + +import android.os.ParcelFileDescriptor +import com.stevesoltys.backup.getRandomByteArray +import com.stevesoltys.backup.transport.TransportTest +import com.stevesoltys.backup.header.HeaderReader +import com.stevesoltys.backup.header.VERSION +import io.mockk.mockk +import java.io.InputStream +import kotlin.random.Random + +internal abstract class RestoreTest : TransportTest() { + + protected val outputFactory = mockk() + protected val headerReader = mockk() + protected val fileDescriptor = mockk() + + protected val token = Random.nextLong() + protected val data = getRandomByteArray() + protected val inputStream = mockk() + + protected val unsupportedVersion = (VERSION + 1).toByte() + +} diff --git a/build.gradle b/build.gradle index aee7f914..26dd7758 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ allprojects { mavenCentral() jcenter() google() + maven { url 'https://jitpack.io' } } } From 1ee443a3d8693ca74fb65588499f2c4addf74b56 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 1 Aug 2019 10:34:31 +0200 Subject: [PATCH 02/10] Add a unique ID to the device folder name to avoid collisions when using several devices of the same model with the same account --- Android.mk | 8 +++++ app/src/main/AndroidManifest.xml | 6 ++-- .../java/com/stevesoltys/backup/Backup.kt | 34 +++++++++++++++++++ .../com/stevesoltys/backup/Base64Utils.kt | 18 ++++++++++ .../com/stevesoltys/backup/header/Header.kt | 4 --- .../stevesoltys/backup/header/HeaderReader.kt | 1 + .../stevesoltys/backup/header/HeaderWriter.kt | 1 + .../backup/settings/SettingsManager.kt | 15 +++++++- .../backup/transport/PluginManager.kt | 10 ++---- .../transport/backup/FullBackupPlugin.kt | 1 + .../backup/transport/backup/KVBackup.kt | 5 ++- .../backup/transport/backup/KVBackupPlugin.kt | 1 + .../plugins/DocumentsProviderBackupPlugin.kt | 5 --- .../backup/plugins/DocumentsStorage.kt | 6 +++- .../backup/transport/restore/KVRestore.kt | 4 +-- .../backup/header/HeaderReaderTest.kt | 1 + .../transport/CoordinatorIntegrationTest.kt | 9 +++-- .../backup/transport/backup/KVBackupTest.kt | 2 +- .../backup/transport/restore/KVRestoreTest.kt | 7 ++-- ...ult-permissions_com.stevesoltys.backup.xml | 6 ++++ 20 files changed, 108 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/stevesoltys/backup/Base64Utils.kt create mode 100644 default-permissions_com.stevesoltys.backup.xml diff --git a/Android.mk b/Android.mk index f3c3d59b..ed863870 100644 --- a/Android.mk +++ b/Android.mk @@ -1,5 +1,13 @@ LOCAL_PATH := $(call my-dir) +include $(CLEAR_VARS) +LOCAL_MODULE := default-permissions_com.stevesoltys.backup.xml +LOCAL_MODULE_CLASS := ETC +LOCAL_MODULE_TAGS := optional +LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/default-permissions +LOCAL_SRC_FILES := $(LOCAL_MODULE) +include $(BUILD_PREBUILT) + include $(CLEAR_VARS) LOCAL_MODULE := permissions_com.stevesoltys.backup.xml LOCAL_MODULE_CLASS := ETC diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6a987969..adcf91b4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,7 +14,9 @@ android:name="android.permission.BACKUP" tools:ignore="ProtectedPermissions" /> - + + + android:label="@string/app_name" /> { - internal val key = String(getUrlDecoder().decode(base64Key)) + internal val key = base64Key.decodeBase64() override fun compareTo(other: DecodedKey) = key.compareTo(other.key) } diff --git a/app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt b/app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt index 94b85b6f..1cbc864c 100644 --- a/app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt +++ b/app/src/test/java/com/stevesoltys/backup/header/HeaderReaderTest.kt @@ -1,5 +1,6 @@ package com.stevesoltys.backup.header +import com.stevesoltys.backup.Utf8 import com.stevesoltys.backup.assertContains import com.stevesoltys.backup.getRandomString import org.junit.jupiter.api.Assertions.* diff --git a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt index 9622568c..7875db5e 100644 --- a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt @@ -10,17 +10,16 @@ import android.os.ParcelFileDescriptor import com.stevesoltys.backup.crypto.CipherFactoryImpl import com.stevesoltys.backup.crypto.CryptoImpl import com.stevesoltys.backup.crypto.KeyManagerTestImpl -import com.stevesoltys.backup.transport.backup.* +import com.stevesoltys.backup.encodeBase64 import com.stevesoltys.backup.header.HeaderReaderImpl import com.stevesoltys.backup.header.HeaderWriterImpl -import com.stevesoltys.backup.header.Utf8 +import com.stevesoltys.backup.transport.backup.* import com.stevesoltys.backup.transport.restore.* import io.mockk.* import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream -import java.util.* import kotlin.random.Random internal class CoordinatorIntegrationTest : TransportTest() { @@ -53,9 +52,9 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val appData = ByteArray(42).apply { Random.nextBytes(this) } private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) } private val key = "RestoreKey" - private val key64 = Base64.getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8)) + private val key64 = key.encodeBase64() private val key2 = "RestoreKey2" - private val key264 = Base64.getUrlEncoder().withoutPadding().encodeToString(key2.toByteArray(Utf8)) + private val key264 = key2.encodeBase64() init { every { backupPlugin.kvBackupPlugin } returns kvBackupPlugin diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt index 5362415b..422c3aab 100644 --- a/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/KVBackupTest.kt @@ -2,9 +2,9 @@ package com.stevesoltys.backup.transport.backup import android.app.backup.BackupDataInput import android.app.backup.BackupTransport.* +import com.stevesoltys.backup.Utf8 import com.stevesoltys.backup.getRandomString import com.stevesoltys.backup.header.MAX_KEY_LENGTH_SIZE -import com.stevesoltys.backup.header.Utf8 import com.stevesoltys.backup.header.VersionHeader import io.mockk.Runs import io.mockk.every diff --git a/app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt index f0237ce8..2d345d2f 100644 --- a/app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/backup/transport/restore/KVRestoreTest.kt @@ -3,9 +3,9 @@ package com.stevesoltys.backup.transport.restore import android.app.backup.BackupDataOutput import android.app.backup.BackupTransport.TRANSPORT_ERROR import android.app.backup.BackupTransport.TRANSPORT_OK +import com.stevesoltys.backup.encodeBase64 import com.stevesoltys.backup.getRandomByteArray import com.stevesoltys.backup.header.UnsupportedVersionException -import com.stevesoltys.backup.header.Utf8 import com.stevesoltys.backup.header.VERSION import com.stevesoltys.backup.header.VersionHeader import io.mockk.* @@ -14,7 +14,6 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test import java.io.IOException import java.io.InputStream -import java.util.Base64.getUrlEncoder import kotlin.random.Random internal class KVRestoreTest : RestoreTest() { @@ -24,10 +23,10 @@ internal class KVRestoreTest : RestoreTest() { private val restore = KVRestore(plugin, outputFactory, headerReader, crypto) private val key = "Restore Key" - private val key64 = getUrlEncoder().withoutPadding().encodeToString(key.toByteArray(Utf8)) + private val key64 = key.encodeBase64() private val versionHeader = VersionHeader(VERSION, packageInfo.packageName, key) private val key2 = "Restore Key2" - private val key264 = getUrlEncoder().withoutPadding().encodeToString(key2.toByteArray(Utf8)) + private val key264 = key2.encodeBase64() private val versionHeader2 = VersionHeader(VERSION, packageInfo.packageName, key2) @Test diff --git a/default-permissions_com.stevesoltys.backup.xml b/default-permissions_com.stevesoltys.backup.xml new file mode 100644 index 00000000..825c0049 --- /dev/null +++ b/default-permissions_com.stevesoltys.backup.xml @@ -0,0 +1,6 @@ + + + + + + From a6e971609c56f77b2440ba4ef02f4e9f216833ee Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 1 Aug 2019 11:44:58 +0200 Subject: [PATCH 03/10] Implement clearing full backup data from storage --- .../stevesoltys/backup/transport/backup/FullBackup.kt | 4 ++-- .../backup/transport/backup/FullBackupPlugin.kt | 5 ++++- .../backup/plugins/DocumentsProviderFullBackup.kt | 6 +++--- .../backup/transport/backup/FullBackupTest.kt | 11 +++++++++-- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt index 0a56c59b..ec2746d5 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackup.kt @@ -152,7 +152,7 @@ class FullBackup( } fun clearBackupData(packageInfo: PackageInfo) { - // TODO + plugin.removeDataOfPackage(packageInfo) } fun cancelFullBackup() { @@ -160,7 +160,7 @@ class FullBackup( val state = this.state ?: throw AssertionError("No state when canceling") clearState() try { - plugin.cancelFullBackup(state.packageInfo) + plugin.removeDataOfPackage(state.packageInfo) } catch (e: IOException) { Log.w(TAG, "Error cancelling full backup for ${state.packageName}", e) } diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt index c1f1a3f0..0e0ee772 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/FullBackupPlugin.kt @@ -12,7 +12,10 @@ interface FullBackupPlugin { @Throws(IOException::class) fun getOutputStream(targetPackage: PackageInfo): OutputStream + /** + * Remove all data associated with the given package. + */ @Throws(IOException::class) - fun cancelFullBackup(targetPackage: PackageInfo) + fun removeDataOfPackage(packageInfo: PackageInfo) } diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt index 2116b926..b6540fa9 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt @@ -23,9 +23,9 @@ class DocumentsProviderFullBackup( } @Throws(IOException::class) - override fun cancelFullBackup(targetPackage: PackageInfo) { - val packageName = targetPackage.packageName - Log.i(TAG, "Deleting $packageName due to canceled backup...") + override fun removeDataOfPackage(packageInfo: PackageInfo) { + val packageName = packageInfo.packageName + Log.i(TAG, "Deleting $packageName...") val file = storage.defaultFullBackupDir?.findFile(packageName) ?: return if (!file.delete()) throw IOException("Failed to delete $packageName") } diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt index b6f1972e..af66c82b 100644 --- a/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/FullBackupTest.kt @@ -174,11 +174,18 @@ internal class FullBackupTest : BackupTest() { assertFalse(backup.hasState()) } + @Test + fun `clearBackupData delegates to plugin`() { + every { plugin.removeDataOfPackage(packageInfo) } just Runs + + backup.clearBackupData(packageInfo) + } + @Test fun `cancel full backup runs ok`() { expectPerformFullBackup() expectClearState() - every { plugin.cancelFullBackup(packageInfo) } just Runs + every { plugin.removeDataOfPackage(packageInfo) } just Runs assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) assertTrue(backup.hasState()) @@ -190,7 +197,7 @@ internal class FullBackupTest : BackupTest() { fun `cancel full backup ignores exception when calling plugin`() { expectPerformFullBackup() expectClearState() - every { plugin.cancelFullBackup(packageInfo) } throws IOException() + every { plugin.removeDataOfPackage(packageInfo) } throws IOException() assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, data)) assertTrue(backup.hasState()) From bd968be0b1c4e2e2ce09e3fd4aedd2099365c22f Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 1 Aug 2019 12:00:06 +0200 Subject: [PATCH 04/10] Remove BackupJobService as the OS is scheduling its own backups --- app/src/main/AndroidManifest.xml | 5 -- .../backup/activity/MainActivity.java | 2 - .../activity/MainActivityController.java | 27 +------- .../backup/service/backup/BackupJobService.kt | 63 ------------------- .../backup/settings/SettingsManager.kt | 13 ---- .../backup/settings/SettingsViewModel.kt | 4 +- .../ConfigurableBackupTransportService.kt | 31 +++++++++ .../backup/crypto/KeyManagerTestImpl.kt | 2 +- 8 files changed, 35 insertions(+), 112 deletions(-) delete mode 100644 app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index adcf91b4..c9d2a459 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -55,10 +55,5 @@ - - diff --git a/app/src/main/java/com/stevesoltys/backup/activity/MainActivity.java b/app/src/main/java/com/stevesoltys/backup/activity/MainActivity.java index e11e8776..162e4628 100644 --- a/app/src/main/java/com/stevesoltys/backup/activity/MainActivity.java +++ b/app/src/main/java/com/stevesoltys/backup/activity/MainActivity.java @@ -11,7 +11,6 @@ import com.stevesoltys.backup.R; import static android.view.View.GONE; import static android.view.View.VISIBLE; -import static com.stevesoltys.backup.settings.SettingsManagerKt.areBackupsScheduled; public class MainActivity extends Activity implements View.OnClickListener { @@ -37,7 +36,6 @@ public class MainActivity extends Activity implements View.OnClickListener { automaticBackupsButton = findViewById(R.id.automatic_backups_button); automaticBackupsButton.setOnClickListener(this); - if (areBackupsScheduled(this)) automaticBackupsButton.setVisibility(GONE); changeLocationButton = findViewById(R.id.change_backup_location_button); changeLocationButton.setOnClickListener(this); diff --git a/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java b/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java index 8fb2aeb2..a264e91b 100644 --- a/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java +++ b/app/src/main/java/com/stevesoltys/backup/activity/MainActivityController.java @@ -1,35 +1,26 @@ package com.stevesoltys.backup.activity; import android.app.Activity; -import android.app.job.JobInfo; -import android.app.job.JobScheduler; import android.content.ActivityNotFoundException; -import android.content.ComponentName; import android.content.Intent; import android.net.Uri; import android.widget.Toast; import com.stevesoltys.backup.activity.backup.CreateBackupActivity; import com.stevesoltys.backup.activity.restore.RestoreBackupActivity; -import com.stevesoltys.backup.service.backup.BackupJobService; import com.stevesoltys.backup.transport.ConfigurableBackupTransportService; -import static android.app.job.JobInfo.NETWORK_TYPE_UNMETERED; import static android.content.Intent.ACTION_OPEN_DOCUMENT; import static android.content.Intent.ACTION_OPEN_DOCUMENT_TREE; import static android.content.Intent.CATEGORY_OPENABLE; import static android.content.Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION; import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; import static android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION; -import static com.stevesoltys.backup.BackupKt.JOB_ID_BACKGROUND_BACKUP; import static com.stevesoltys.backup.activity.MainActivity.OPEN_DOCUMENT_TREE_BACKUP_REQUEST_CODE; import static com.stevesoltys.backup.activity.MainActivity.OPEN_DOCUMENT_TREE_REQUEST_CODE; import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupFolderUri; import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword; import static com.stevesoltys.backup.settings.SettingsManagerKt.setBackupFolderUri; -import static com.stevesoltys.backup.settings.SettingsManagerKt.setBackupsScheduled; -import static java.util.Objects.requireNonNull; -import static java.util.concurrent.TimeUnit.DAYS; /** * @author Steve Soltys @@ -89,24 +80,8 @@ public class MainActivityController { return false; } - // schedule backups - final ComponentName serviceName = new ComponentName(parent, BackupJobService.class); - JobInfo job = new JobInfo.Builder(JOB_ID_BACKGROUND_BACKUP, serviceName) - .setRequiredNetworkType(NETWORK_TYPE_UNMETERED) - .setRequiresBatteryNotLow(true) - .setRequiresStorageNotLow(true) // TODO warn the user instead - .setPeriodic(DAYS.toMillis(1)) - .setRequiresCharging(true) - .setPersisted(true) - .build(); - JobScheduler scheduler = requireNonNull(parent.getSystemService(JobScheduler.class)); - scheduler.schedule(job); - - // remember that backups were scheduled - setBackupsScheduled(parent); - // show Toast informing the user - Toast.makeText(parent, "Backups will run automatically now", Toast.LENGTH_SHORT).show(); + Toast.makeText(parent, "REMOVED", Toast.LENGTH_SHORT).show(); return true; } diff --git a/app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.kt b/app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.kt deleted file mode 100644 index 00fbbd64..00000000 --- a/app/src/main/java/com/stevesoltys/backup/service/backup/BackupJobService.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.stevesoltys.backup.service.backup - -import android.app.backup.BackupManager -import android.app.backup.BackupManager.FLAG_NON_INCREMENTAL_BACKUP -import android.app.backup.BackupTransport.FLAG_USER_INITIATED -import android.app.job.JobParameters -import android.app.job.JobService -import android.content.Context -import android.content.Context.BACKUP_SERVICE -import android.content.Intent -import android.os.RemoteException -import android.util.Log -import androidx.annotation.WorkerThread -import com.stevesoltys.backup.Backup -import com.stevesoltys.backup.NotificationBackupObserver -import com.stevesoltys.backup.service.PackageService -import com.stevesoltys.backup.session.backup.BackupMonitor -import com.stevesoltys.backup.transport.ConfigurableBackupTransportService - -private val TAG = BackupJobService::class.java.name - -// TODO might not be needed, if the OS really schedules backups on its own -class BackupJobService : JobService() { - - override fun onStartJob(params: JobParameters): Boolean { - Log.i(TAG, "Triggering full backup") - try { - requestFullBackup(this) - } finally { - jobFinished(params, false) - } - return true - } - - override fun onStopJob(params: JobParameters): Boolean { - try { - Backup.backupManager.cancelBackups() - } catch (e: RemoteException) { - Log.e(TAG, "Error cancelling backup: ", e) - } - return true - } - -} - -@WorkerThread -fun requestFullBackup(context: Context) { - context.startService(Intent(context, ConfigurableBackupTransportService::class.java)) - val observer = NotificationBackupObserver(context, true) - val flags = FLAG_NON_INCREMENTAL_BACKUP or FLAG_USER_INITIATED - val packages = PackageService().eligiblePackages - val result = try { - Backup.backupManager.requestBackup(packages, observer, BackupMonitor(), flags) - } catch (e: RemoteException) { - // TODO show notification on backup error - Log.e(TAG, "Error during backup: ", e) - } - if (result == BackupManager.SUCCESS) { - Log.i(TAG, "Backup succeeded ") - } else { - Log.e(TAG, "Backup failed: $result") - } -} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt index 1cb14399..0bd9fdfd 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt @@ -48,16 +48,3 @@ fun setBackupPassword(context: Context, password: String) { fun getBackupPassword(context: Context): String? { return getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_PASSWORD, null) } - -@Deprecated("Our own scheduling will be removed") -fun setBackupsScheduled(context: Context) { - getDefaultSharedPreferences(context) - .edit() - .putBoolean(PREF_KEY_BACKUPS_SCHEDULED, true) - .apply() -} - -@Deprecated("Our own scheduling will be removed") -fun areBackupsScheduled(context: Context): Boolean { - return getDefaultSharedPreferences(context).getBoolean(PREF_KEY_BACKUPS_SCHEDULED, false) -} diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt index 4c243eba..7ba92a1c 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt @@ -9,8 +9,8 @@ import androidx.lifecycle.AndroidViewModel import com.stevesoltys.backup.Backup import com.stevesoltys.backup.LiveEvent import com.stevesoltys.backup.MutableLiveEvent -import com.stevesoltys.backup.service.backup.requestFullBackup import com.stevesoltys.backup.transport.ConfigurableBackupTransportService +import com.stevesoltys.backup.transport.requestBackup private val TAG = SettingsViewModel::class.java.simpleName @@ -54,6 +54,6 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application Log.d(TAG, "New storage location chosen: $folderUri") } - fun backupNow() = Thread { requestFullBackup(app) }.start() + fun backupNow() = Thread { requestBackup(app) }.start() } diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt index f178245d..c2b54127 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransportService.kt @@ -1,9 +1,21 @@ package com.stevesoltys.backup.transport import android.app.Service +import android.app.backup.BackupManager +import android.app.backup.BackupTransport +import android.content.Context +import android.content.Context.BACKUP_SERVICE +import android.app.backup.BackupManager.FLAG_NON_INCREMENTAL_BACKUP +import android.app.backup.BackupTransport.FLAG_USER_INITIATED import android.content.Intent import android.os.IBinder +import android.os.RemoteException import android.util.Log +import androidx.annotation.WorkerThread +import com.stevesoltys.backup.Backup +import com.stevesoltys.backup.NotificationBackupObserver +import com.stevesoltys.backup.service.PackageService +import com.stevesoltys.backup.session.backup.BackupMonitor private val TAG = ConfigurableBackupTransportService::class.java.simpleName @@ -35,3 +47,22 @@ class ConfigurableBackupTransportService : Service() { } } + +@WorkerThread +fun requestBackup(context: Context) { + context.startService(Intent(context, ConfigurableBackupTransportService::class.java)) + val observer = NotificationBackupObserver(context, true) + val flags = FLAG_NON_INCREMENTAL_BACKUP or FLAG_USER_INITIATED + val packages = PackageService().eligiblePackages + val result = try { + Backup.backupManager.requestBackup(packages, observer, BackupMonitor(), flags) + } catch (e: RemoteException) { + // TODO show notification on backup error + Log.e(TAG, "Error during backup: ", e) + } + if (result == BackupManager.SUCCESS) { + Log.i(TAG, "Backup succeeded ") + } else { + Log.e(TAG, "Backup failed: $result") + } +} diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt b/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt index e9473c89..754e9309 100644 --- a/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt +++ b/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt @@ -12,7 +12,7 @@ class KeyManagerTestImpl : KeyManager { } override fun storeBackupKey(seed: ByteArray) { - TODO("not implemented") + throw NotImplementedError("not implemented") } override fun hasBackupKey(): Boolean { From e2a3e3d2b796f38270e9ef2213cc18af572bcc89 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 1 Aug 2019 12:06:15 +0200 Subject: [PATCH 05/10] Raise importance of backup notifications to show them in status bar --- .../java/com/stevesoltys/backup/NotificationBackupObserver.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt index b4d01bb8..035b9f69 100644 --- a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt +++ b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt @@ -2,7 +2,7 @@ package com.stevesoltys.backup import android.app.NotificationChannel import android.app.NotificationManager -import android.app.NotificationManager.IMPORTANCE_MIN +import android.app.NotificationManager.IMPORTANCE_LOW import android.app.backup.BackupProgress import android.app.backup.IBackupObserver import android.content.Context @@ -25,7 +25,7 @@ class NotificationBackupObserver( private val pm = context.packageManager private val nm = context.getSystemService(NotificationManager::class.java).apply { val title = context.getString(R.string.notification_channel_title) - val channel = NotificationChannel(CHANNEL_ID, title, IMPORTANCE_MIN).apply { + val channel = NotificationChannel(CHANNEL_ID, title, IMPORTANCE_LOW).apply { enableVibration(false) } createNotificationChannel(channel) From 2685f2b48ad448a5c648a76015ed2c69725ac301 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 5 Aug 2019 11:07:55 +0200 Subject: [PATCH 06/10] Don't show rejected packages (usually 0 size) as failed Also change notification ID to not collide with Nextcloud's ID. --- .../stevesoltys/backup/NotificationBackupObserver.kt | 12 +++++++----- app/src/main/res/values/strings.xml | 1 + 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt index 035b9f69..8729e623 100644 --- a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt +++ b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt @@ -4,6 +4,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.NotificationManager.IMPORTANCE_LOW import android.app.backup.BackupProgress +import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED import android.app.backup.IBackupObserver import android.content.Context import android.util.Log @@ -14,7 +15,7 @@ import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT import androidx.core.app.NotificationCompat.PRIORITY_LOW private const val CHANNEL_ID = "NotificationBackupObserver" -private const val NOTIFICATION_ID = 1 +private const val NOTIFICATION_ID = 1042 private val TAG = NotificationBackupObserver::class.java.simpleName @@ -70,10 +71,11 @@ class NotificationBackupObserver( if (isLoggable(TAG, INFO)) { Log.i(TAG, "Completed. Target: $target, status: $status") } - val title = context.getString( - if (status == 0) R.string.notification_backup_result_complete - else R.string.notification_backup_result_error - ) + val title = context.getString(when (status) { + 0 -> R.string.notification_backup_result_complete + TRANSPORT_PACKAGE_REJECTED -> R.string.notification_backup_result_rejected + else -> R.string.notification_backup_result_error + }) val notification = notificationBuilder.apply { setContentTitle(title) setContentText(getAppName(target)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e06e4f21..1ac6ec6d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,6 +62,7 @@ Backup Notification Backup running Backup complete + Not backed up Backup failed From e955e021fd8dd7353748b596eda9d02a3f191e88 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 6 Aug 2019 08:16:37 +0200 Subject: [PATCH 07/10] Add a simple instrumentation test for testing on real devices --- .travis.yml | 4 +- app/build.gradle | 4 ++ .../java/com/stevesoltys/backup/Test.kt | 70 +++++++++++++++++++ .../plugins/DocumentsProviderBackupPlugin.kt | 2 +- 4 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/com/stevesoltys/backup/Test.kt diff --git a/.travis.yml b/.travis.yml index dd23f68d..64b5f022 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,6 +20,8 @@ before_cache: - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ +script: ./gradlew check + cache: directories: - $HOME/.gradle/caches/ @@ -37,4 +39,4 @@ deploy: on: repo: stevesoltys/backup tags: true - skip_cleanup: true \ No newline at end of file + skip_cleanup: true diff --git a/app/build.gradle b/app/build.gradle index cd662b64..88a2f425 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,6 +12,7 @@ android { defaultConfig { minSdkVersion 26 targetSdkVersion 28 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -108,4 +109,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-api:5.5.0' testImplementation 'io.mockk:mockk:1.9.3' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.5.0' + + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test:rules:1.2.0' } diff --git a/app/src/androidTest/java/com/stevesoltys/backup/Test.kt b/app/src/androidTest/java/com/stevesoltys/backup/Test.kt new file mode 100644 index 00000000..1dafbfea --- /dev/null +++ b/app/src/androidTest/java/com/stevesoltys/backup/Test.kt @@ -0,0 +1,70 @@ +package com.stevesoltys.backup + +import androidx.documentfile.provider.DocumentFile +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import com.stevesoltys.backup.settings.getBackupFolderUri +import com.stevesoltys.backup.transport.backup.plugins.DocumentsStorage +import com.stevesoltys.backup.transport.backup.plugins.createOrGetFile +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.random.Random + +private const val filename = "test-file" + +@RunWith(AndroidJUnit4::class) +class AndroidUnitTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val folderUri = getBackupFolderUri(context) + private val deviceName = "device name" + private val storage = DocumentsStorage(context, folderUri, deviceName) + + private lateinit var file: DocumentFile + + @Before + fun setup() { + assertNotNull("Select a storage location in the app first!", storage.rootBackupDir) + file = storage.rootBackupDir?.createOrGetFile(filename) + ?: throw RuntimeException("Could not create test file") + } + + @After + fun tearDown() { + file.delete() + } + + @Test + fun testWritingAndReadingFile() { + // write to output stream + val outputStream = storage.getOutputStream(file) + val content = ByteArray(1337).apply { Random.nextBytes(this) } + outputStream.write(content) + outputStream.flush() + outputStream.close() + + // read written data from input stream + val inputStream = storage.getInputStream(file) + val readContent = inputStream.readBytes() + inputStream.close() + assertArrayEquals(content, readContent) + + // write smaller content to same file + val outputStream2 = storage.getOutputStream(file) + val content2 = ByteArray(42).apply { Random.nextBytes(this) } + outputStream2.write(content2) + outputStream2.flush() + outputStream2.close() + + // read written data from input stream + val inputStream2 = storage.getInputStream(file) + val readContent2 = inputStream2.readBytes() + inputStream2.close() + assertArrayEquals(content2, readContent2) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt index fe6ee589..e15466c1 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderBackupPlugin.kt @@ -21,7 +21,7 @@ class DocumentsProviderBackupPlugin( @Throws(IOException::class) override fun initializeDevice() { // get or create root backup dir - val rootDir = storage.rootBackupDir ?: throw IOException() + storage.rootBackupDir ?: throw IOException() // create backup folders val kvDir = storage.defaultKvBackupDir From 74aa62a2642bc4b05410ff9315490973272b3f34 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 20 Aug 2019 13:04:09 +0200 Subject: [PATCH 08/10] Add instrumentation test for testing AES/GCM nonces are really unique --- app/build.gradle | 9 +++++ .../backup/CipherUniqueNonceTest.kt | 33 +++++++++++++++++++ .../{Test.kt => DocumentsStorageTest.kt} | 2 +- .../java/com/stevesoltys/backup/TestUtils.kt | 4 +-- .../backup/crypto/KeyManagerTestImpl.kt | 0 5 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/java/com/stevesoltys/backup/CipherUniqueNonceTest.kt rename app/src/androidTest/java/com/stevesoltys/backup/{Test.kt => DocumentsStorageTest.kt} (98%) rename app/src/{test => sharedTest}/java/com/stevesoltys/backup/TestUtils.kt (84%) rename app/src/{test => sharedTest}/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt (100%) diff --git a/app/build.gradle b/app/build.gradle index 88a2f425..8fe39a43 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,6 +37,15 @@ android { } } + sourceSets { + test { + java.srcDirs += "$projectDir/src/sharedTest/java" + } + androidTest { + java.srcDirs += "$projectDir/src/sharedTest/java" + } + } + // optional signingConfigs def keystorePropertiesFile = rootProject.file("keystore.properties") if (keystorePropertiesFile.exists()) { diff --git a/app/src/androidTest/java/com/stevesoltys/backup/CipherUniqueNonceTest.kt b/app/src/androidTest/java/com/stevesoltys/backup/CipherUniqueNonceTest.kt new file mode 100644 index 00000000..f2ccaba0 --- /dev/null +++ b/app/src/androidTest/java/com/stevesoltys/backup/CipherUniqueNonceTest.kt @@ -0,0 +1,33 @@ +package com.stevesoltys.backup + +import android.util.Log +import androidx.test.filters.LargeTest +import androidx.test.runner.AndroidJUnit4 +import com.stevesoltys.backup.crypto.CipherFactoryImpl +import com.stevesoltys.backup.crypto.KeyManagerTestImpl +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +private val TAG = CipherUniqueNonceTest::class.java.simpleName +private const val ITERATIONS = 1_000_000 + +@LargeTest +@RunWith(AndroidJUnit4::class) +class CipherUniqueNonceTest { + + private val keyManager = KeyManagerTestImpl() + private val cipherFactory = CipherFactoryImpl(keyManager) + + private val nonceSet = HashSet() + + @Test + fun testUniqueNonce() { + for (i in 1..ITERATIONS) { + val iv = cipherFactory.createEncryptionCipher().iv + Log.w(TAG, "$i: ${iv.toHexString()}") + assertTrue(nonceSet.add(iv)) + } + } + +} diff --git a/app/src/androidTest/java/com/stevesoltys/backup/Test.kt b/app/src/androidTest/java/com/stevesoltys/backup/DocumentsStorageTest.kt similarity index 98% rename from app/src/androidTest/java/com/stevesoltys/backup/Test.kt rename to app/src/androidTest/java/com/stevesoltys/backup/DocumentsStorageTest.kt index 1dafbfea..96b89f71 100644 --- a/app/src/androidTest/java/com/stevesoltys/backup/Test.kt +++ b/app/src/androidTest/java/com/stevesoltys/backup/DocumentsStorageTest.kt @@ -17,7 +17,7 @@ import kotlin.random.Random private const val filename = "test-file" @RunWith(AndroidJUnit4::class) -class AndroidUnitTest { +class DocumentsStorageTest { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val folderUri = getBackupFolderUri(context) diff --git a/app/src/test/java/com/stevesoltys/backup/TestUtils.kt b/app/src/sharedTest/java/com/stevesoltys/backup/TestUtils.kt similarity index 84% rename from app/src/test/java/com/stevesoltys/backup/TestUtils.kt rename to app/src/sharedTest/java/com/stevesoltys/backup/TestUtils.kt index 6644f3b1..3802de2d 100644 --- a/app/src/test/java/com/stevesoltys/backup/TestUtils.kt +++ b/app/src/sharedTest/java/com/stevesoltys/backup/TestUtils.kt @@ -1,11 +1,9 @@ package com.stevesoltys.backup -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Assertions.fail import kotlin.random.Random fun assertContains(stack: String?, needle: String) { - assertTrue(stack?.contains(needle) ?: fail()) + if (stack?.contains(needle) != true) throw AssertionError() } fun getRandomByteArray(size: Int = Random.nextInt(1337)) = ByteArray(size).apply { diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt b/app/src/sharedTest/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt similarity index 100% rename from app/src/test/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt rename to app/src/sharedTest/java/com/stevesoltys/backup/crypto/KeyManagerTestImpl.kt From 683268a15f2af558d4a997b92dcb0d29faa39b91 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 2 Sep 2019 11:45:57 -0300 Subject: [PATCH 09/10] Add a unit test for checking word list integrity Also does minor clean-ups --- app/src/main/AndroidManifest.xml | 2 +- .../main/java/com/stevesoltys/backup/Backup.kt | 6 ------ .../backup/CreateBackupActivityController.java | 2 -- .../backup/settings/RecoveryCodeViewModel.kt | 5 ----- .../backup/settings/SettingsManager.kt | 12 ------------ .../transport/ConfigurableBackupTransport.kt | 1 + .../plugins/DocumentsProviderFullBackup.kt | 1 - .../plugins/DocumentsProviderKVBackup.kt | 1 - .../stevesoltys/backup/crypto/WordListTest.kt | 18 ++++++++++++++++++ 9 files changed, 20 insertions(+), 28 deletions(-) create mode 100644 app/src/test/java/com/stevesoltys/backup/crypto/WordListTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c9d2a459..4665b8cb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,7 +48,7 @@ android:parentActivityName="com.stevesoltys.backup.activity.MainActivity" /> diff --git a/app/src/main/java/com/stevesoltys/backup/Backup.kt b/app/src/main/java/com/stevesoltys/backup/Backup.kt index 5bb608d3..a71caef0 100644 --- a/app/src/main/java/com/stevesoltys/backup/Backup.kt +++ b/app/src/main/java/com/stevesoltys/backup/Backup.kt @@ -1,6 +1,5 @@ package com.stevesoltys.backup -import android.Manifest import android.Manifest.permission.READ_PHONE_STATE import android.app.Application import android.app.backup.IBackupManager @@ -13,12 +12,7 @@ import com.stevesoltys.backup.crypto.KeyManager import com.stevesoltys.backup.crypto.KeyManagerImpl import com.stevesoltys.backup.settings.getDeviceName import com.stevesoltys.backup.settings.setDeviceName -import io.github.novacrypto.hashing.Sha256 -import io.github.novacrypto.hashing.Sha256.sha256 import io.github.novacrypto.hashing.Sha256.sha256Twice -import java.lang.AssertionError - -const val JOB_ID_BACKGROUND_BACKUP = 1 private val TAG = Backup::class.java.simpleName diff --git a/app/src/main/java/com/stevesoltys/backup/activity/backup/CreateBackupActivityController.java b/app/src/main/java/com/stevesoltys/backup/activity/backup/CreateBackupActivityController.java index dc43bf28..f83d1a8c 100644 --- a/app/src/main/java/com/stevesoltys/backup/activity/backup/CreateBackupActivityController.java +++ b/app/src/main/java/com/stevesoltys/backup/activity/backup/CreateBackupActivityController.java @@ -22,7 +22,6 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import static com.stevesoltys.backup.settings.SettingsManagerKt.getBackupPassword; -import static com.stevesoltys.backup.settings.SettingsManagerKt.setBackupPassword; /** * @author Steve Soltys @@ -116,7 +115,6 @@ class CreateBackupActivityController { String password = passwordTextView.getText().toString(); if (originalPassword.equals(password)) { - setBackupPassword(parent, password); backupService.backupPackageData(selectedPackages, parent); } else { diff --git a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt index 59643546..0083e9a0 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/RecoveryCodeViewModel.kt @@ -1,7 +1,6 @@ package com.stevesoltys.backup.settings import android.app.Application -import android.util.ByteStringUtils.toHexString import androidx.lifecycle.AndroidViewModel import com.stevesoltys.backup.Backup import com.stevesoltys.backup.LiveEvent @@ -21,7 +20,6 @@ class RecoveryCodeViewModel(application: Application) : AndroidViewModel(applica internal val wordList: List by lazy { val items: ArrayList = ArrayList(WORD_NUM) - // TODO factor out entropy generation val entropy = ByteArray(Words.TWELVE.byteLength()) SecureRandom().nextBytes(entropy) MnemonicGenerator(English.INSTANCE).createMnemonic(entropy) { @@ -50,9 +48,6 @@ class RecoveryCodeViewModel(application: Application) : AndroidViewModel(applica val seed = SeedCalculator(JavaxPBKDF2WithHmacSHA512.INSTANCE).calculateSeed(mnemonic, "") Backup.keyManager.storeBackupKey(seed) - // TODO remove once encryption/decryption uses key from KeyStore - setBackupPassword(getApplication(), toHexString(seed)) - mRecoveryCodeSaved.setEvent(true) } diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt index 0bd9fdfd..8fad7489 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsManager.kt @@ -32,18 +32,6 @@ fun getDeviceName(context: Context): String? { return getDefaultSharedPreferences(context).getString(PREF_KEY_DEVICE_NAME, null) } -/** - * This is insecure and not supposed to be part of a release, - * but rather an intermediate step towards a generated passphrase. - */ -@Deprecated("Replaced by KeyManager#storeBackupKey()") -fun setBackupPassword(context: Context, password: String) { - getDefaultSharedPreferences(context) - .edit() - .putString(PREF_KEY_BACKUP_PASSWORD, password) - .apply() -} - @Deprecated("Replaced by KeyManager#getBackupKey()") fun getBackupPassword(context: Context): String? { return getDefaultSharedPreferences(context).getString(PREF_KEY_BACKUP_PASSWORD, null) diff --git a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt index 53e47cf0..bfc32f0b 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/ConfigurableBackupTransport.kt @@ -110,6 +110,7 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont } override fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int { + Log.w(TAG, "Warning: Legacy performFullBackup() method called.") return backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0) } diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt index b6540fa9..2ef6149a 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderFullBackup.kt @@ -16,7 +16,6 @@ class DocumentsProviderFullBackup( @Throws(IOException::class) override fun getOutputStream(targetPackage: PackageInfo): OutputStream { - // TODO test file-size after overwriting bigger file val file = storage.defaultFullBackupDir?.createOrGetFile(targetPackage.packageName) ?: throw IOException() return storage.getOutputStream(file) diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt index dd6a08ca..5e4b86d8 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/plugins/DocumentsProviderKVBackup.kt @@ -46,7 +46,6 @@ class DocumentsProviderKVBackup(private val storage: DocumentsStorage) : KVBacku override fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream { val packageFile = this.packageFile ?: throw AssertionError() packageFile.assertRightFile(packageInfo) - // TODO check what happens if we overwrite a bigger file val keyFile = packageFile.createOrGetFile(key) return storage.getOutputStream(keyFile) } diff --git a/app/src/test/java/com/stevesoltys/backup/crypto/WordListTest.kt b/app/src/test/java/com/stevesoltys/backup/crypto/WordListTest.kt new file mode 100644 index 00000000..8fcc97c2 --- /dev/null +++ b/app/src/test/java/com/stevesoltys/backup/crypto/WordListTest.kt @@ -0,0 +1,18 @@ +package com.stevesoltys.backup.crypto + +import io.github.novacrypto.bip39.wordlists.English +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class WordListTest { + + private val words = arrayOf("abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid", "acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual", "adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance", "advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent", "agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album", "alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone", "alpha", "already", "also", "alter", "always", "amateur", "amazing", "among", "amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry", "animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique", "anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april", "arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor", "army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact", "artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume", "asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction", "audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado", "avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis", "baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball", "bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base", "basic", "basket", "battle", "beach", "bean", "beauty", "because", "become", "beef", "before", "begin", "behave", "behind", "believe", "below", "belt", "bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle", "bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black", "blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood", "blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body", "boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring", "borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain", "brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief", "bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother", "brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb", "bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus", "business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable", "cactus", "cage", "cake", "call", "calm", "camera", "camp", "can", "canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable", "capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry", "cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog", "catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling", "celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk", "champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap", "check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child", "chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar", "cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify", "claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff", "climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud", "clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut", "code", "coffee", "coil", "coin", "collect", "color", "column", "combine", "come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm", "congress", "connect", "consider", "control", "convince", "cook", "cool", "copper", "copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch", "country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle", "craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream", "credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop", "cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch", "crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious", "current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad", "damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn", "day", "deal", "debate", "debris", "decade", "december", "decide", "decline", "decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay", "deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend", "deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk", "despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram", "dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital", "dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover", "disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide", "divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain", "donate", "donkey", "donor", "door", "dose", "double", "dove", "draft", "dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill", "drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb", "dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager", "eagle", "early", "earn", "earth", "easily", "east", "easy", "echo", "ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight", "either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator", "elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ", "empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy", "energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough", "enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode", "equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt", "escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil", "evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude", "excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit", "exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend", "extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint", "faith", "fall", "false", "fame", "family", "famous", "fan", "fancy", "fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault", "favorite", "feature", "february", "federal", "fee", "feed", "feel", "female", "fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field", "figure", "file", "film", "filter", "final", "find", "fine", "finger", "finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness", "fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight", "flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly", "foam", "focus", "fog", "foil", "fold", "follow", "food", "foot", "force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil", "foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend", "fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel", "fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy", "gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment", "gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius", "genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle", "ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass", "glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue", "goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip", "govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass", "gravity", "great", "green", "grid", "grief", "grit", "grocery", "group", "grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun", "gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy", "harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard", "head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet", "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip", "hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow", "home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital", "host", "hotel", "hour", "hover", "hub", "huge", "human", "humble", "humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband", "hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill", "illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose", "improve", "impulse", "inch", "include", "income", "increase", "index", "indicate", "indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial", "inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane", "insect", "inside", "inspire", "install", "intact", "interest", "into", "invest", "invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory", "jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel", "job", "join", "joke", "journey", "joy", "judge", "juice", "jump", "jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup", "key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit", "kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know", "lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language", "laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law", "lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave", "lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend", "length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty", "library", "license", "life", "lift", "light", "like", "limb", "limit", "link", "lion", "liquid", "list", "little", "live", "lizard", "load", "loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop", "lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber", "lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet", "maid", "mail", "main", "major", "make", "mammal", "man", "manage", "mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin", "marine", "market", "marriage", "mask", "mass", "master", "match", "material", "math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure", "meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory", "mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message", "metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind", "minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake", "mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment", "monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning", "mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie", "much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music", "must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin", "narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative", "neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral", "never", "news", "next", "nice", "night", "noble", "noise", "nominee", "noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice", "novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey", "object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean", "october", "odor", "off", "offer", "office", "often", "oil", "okay", "old", "olive", "olympic", "omit", "once", "one", "onion", "online", "only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit", "orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich", "other", "outdoor", "outer", "output", "outside", "oval", "oven", "over", "own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page", "pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper", "parade", "parent", "park", "parrot", "party", "pass", "patch", "path", "patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut", "pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper", "perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical", "piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot", "pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet", "plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge", "poem", "poet", "point", "polar", "pole", "police", "pond", "pony", "pool", "popular", "portion", "position", "possible", "post", "potato", "pottery", "poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare", "present", "pretty", "prevent", "price", "pride", "primary", "print", "priority", "prison", "private", "prize", "problem", "process", "produce", "profit", "program", "project", "promote", "proof", "property", "prosper", "protect", "proud", "provide", "public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil", "puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle", "pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz", "quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail", "rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid", "rare", "rate", "rather", "raven", "raw", "razor", "ready", "real", "reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle", "reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject", "relax", "release", "relief", "rely", "remain", "remember", "remind", "remove", "render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report", "require", "rescue", "resemble", "resist", "resource", "response", "result", "retire", "retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib", "ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid", "ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road", "roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room", "rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude", "rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness", "safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same", "sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say", "scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science", "scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea", "search", "season", "seat", "second", "secret", "section", "security", "seed", "seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence", "series", "service", "session", "settle", "setup", "seven", "shadow", "shaft", "shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine", "ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder", "shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side", "siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar", "simple", "since", "sing", "siren", "sister", "situate", "six", "size", "skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab", "slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan", "slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth", "snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social", "sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve", "someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup", "source", "south", "space", "spare", "spatial", "spawn", "speak", "special", "speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin", "spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray", "spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium", "staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay", "steak", "steel", "stem", "step", "stereo", "stick", "still", "sting", "stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street", "strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject", "submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest", "suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme", "sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain", "swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim", "swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table", "tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target", "task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten", "tenant", "tennis", "tent", "term", "test", "text", "thank", "that", "theme", "then", "theory", "there", "they", "thing", "this", "thought", "three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger", "tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title", "toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token", "tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top", "topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist", "toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic", "train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree", "trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy", "trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try", "tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle", "twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical", "ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo", "unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown", "unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon", "upper", "upset", "urban", "urge", "usage", "use", "used", "useful", "useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley", "valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle", "velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very", "vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view", "village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual", "vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote", "voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want", "warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave", "way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding", "weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat", "wheel", "when", "where", "whip", "whisper", "wide", "width", "wife", "wild", "will", "win", "window", "wine", "wing", "wink", "winner", "winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman", "wonder", "wood", "wool", "word", "work", "world", "worry", "worth", "wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year", "yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo") + + @Test + fun `word list of library did not change`() { + for (i in words.indices) { + assertEquals(words[i], English.INSTANCE.getWord(i)) + } + } + +} From c714a4e7e11c2187369af5deefef63b80708e2ce Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 2 Sep 2019 17:01:12 -0300 Subject: [PATCH 10/10] Show error notification when backup fails The implementation is rudimentary for now. E.g. The notification is only shown when a device init fails which seems to be triggered after the first failure. --- .../java/com/stevesoltys/backup/Backup.kt | 9 ++ .../backup/BackupNotificationManager.kt | 93 +++++++++++++++++++ .../backup/NotificationBackupObserver.kt | 51 ++-------- .../backup/settings/SettingsActivity.kt | 9 +- .../backup/settings/SettingsViewModel.kt | 14 ++- .../backup/transport/PluginManager.kt | 3 +- .../transport/backup/BackupCoordinator.kt | 5 +- app/src/main/res/drawable/ic_cloud_error.xml | 10 ++ app/src/main/res/values/strings.xml | 5 + .../transport/CoordinatorIntegrationTest.kt | 4 +- .../transport/backup/BackupCoordinatorTest.kt | 5 +- build.gradle | 2 +- 12 files changed, 156 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/com/stevesoltys/backup/BackupNotificationManager.kt create mode 100644 app/src/main/res/drawable/ic_cloud_error.xml diff --git a/app/src/main/java/com/stevesoltys/backup/Backup.kt b/app/src/main/java/com/stevesoltys/backup/Backup.kt index a71caef0..1efe3c72 100644 --- a/app/src/main/java/com/stevesoltys/backup/Backup.kt +++ b/app/src/main/java/com/stevesoltys/backup/Backup.kt @@ -5,6 +5,7 @@ import android.app.Application import android.app.backup.IBackupManager import android.content.Context.BACKUP_SERVICE import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.net.Uri import android.os.Build import android.os.ServiceManager.getService import android.util.Log @@ -14,6 +15,8 @@ import com.stevesoltys.backup.settings.getDeviceName import com.stevesoltys.backup.settings.setDeviceName import io.github.novacrypto.hashing.Sha256.sha256Twice +private const val URI_AUTHORITY_EXTERNAL_STORAGE = "com.android.externalstorage.documents" + private val TAG = Backup::class.java.simpleName /** @@ -31,6 +34,10 @@ class Backup : Application() { } } + val notificationManager by lazy { + BackupNotificationManager(this) + } + override fun onCreate() { super.onCreate() storeDeviceName() @@ -53,3 +60,5 @@ class Backup : Application() { } } + +fun Uri.isOnExternalStorage() = authority == URI_AUTHORITY_EXTERNAL_STORAGE diff --git a/app/src/main/java/com/stevesoltys/backup/BackupNotificationManager.kt b/app/src/main/java/com/stevesoltys/backup/BackupNotificationManager.kt new file mode 100644 index 00000000..51b9317e --- /dev/null +++ b/app/src/main/java/com/stevesoltys/backup/BackupNotificationManager.kt @@ -0,0 +1,93 @@ +package com.stevesoltys.backup + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.NotificationManager.IMPORTANCE_DEFAULT +import android.app.NotificationManager.IMPORTANCE_LOW +import android.app.PendingIntent +import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat.* +import com.stevesoltys.backup.settings.SettingsActivity + +private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver" +private const val CHANNEL_ID_ERROR = "NotificationError" +private const val NOTIFICATION_ID_OBSERVER = 1 +private const val NOTIFICATION_ID_ERROR = 2 + +class BackupNotificationManager(private val context: Context) { + + private val nm = context.getSystemService(NotificationManager::class.java)!!.apply { + createNotificationChannel(getObserverChannel()) + createNotificationChannel(getErrorChannel()) + } + + private fun getObserverChannel(): NotificationChannel { + val title = context.getString(R.string.notification_channel_title) + return NotificationChannel(CHANNEL_ID_OBSERVER, title, IMPORTANCE_LOW).apply { + enableVibration(false) + } + } + + private fun getErrorChannel(): NotificationChannel { + val title = context.getString(R.string.notification_error_channel_title) + return NotificationChannel(CHANNEL_ID_ERROR, title, IMPORTANCE_DEFAULT) + } + + private val observerBuilder = Builder(context, CHANNEL_ID_OBSERVER).apply { + setSmallIcon(R.drawable.ic_cloud_upload) + } + + private val errorBuilder = Builder(context, CHANNEL_ID_ERROR).apply { + setSmallIcon(R.drawable.ic_cloud_error) + } + + fun onBackupUpdate(app: CharSequence, transferred: Int, expected: Int, userInitiated: Boolean) { + val notification = observerBuilder.apply { + setContentTitle(context.getString(R.string.notification_title)) + setContentText(app) + setProgress(expected, transferred, false) + priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW + }.build() + nm.notify(NOTIFICATION_ID_OBSERVER, notification) + } + + fun onBackupResult(app: CharSequence, status: Int, userInitiated: Boolean) { + val title = context.getString(when (status) { + 0 -> R.string.notification_backup_result_complete + TRANSPORT_PACKAGE_REJECTED -> R.string.notification_backup_result_rejected + else -> R.string.notification_backup_result_error + }) + val notification = observerBuilder.apply { + setContentTitle(title) + setContentText(app) + priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW + }.build() + nm.notify(NOTIFICATION_ID_OBSERVER, notification) + } + + fun onBackupFinished() { + nm.cancel(NOTIFICATION_ID_OBSERVER) + } + + fun onBackupError() { + val intent = Intent(context, SettingsActivity::class.java) + val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0) + val actionText = context.getString(R.string.notification_error_action) + val action = Action(R.drawable.ic_storage, actionText, pendingIntent) + val notification = errorBuilder.apply { + setContentTitle(context.getString(R.string.notification_error_title)) + setContentText(context.getString(R.string.notification_error_text)) + addAction(action) + setOnlyAlertOnce(true) + setAutoCancel(true) + }.build() + nm.notify(NOTIFICATION_ID_ERROR, notification) + } + + fun onBackupErrorSeen() { + nm.cancel(NOTIFICATION_ID_ERROR) + } + +} diff --git a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt index 8729e623..de510f80 100644 --- a/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt +++ b/app/src/main/java/com/stevesoltys/backup/NotificationBackupObserver.kt @@ -1,40 +1,18 @@ package com.stevesoltys.backup -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.NotificationManager.IMPORTANCE_LOW import android.app.backup.BackupProgress -import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED import android.app.backup.IBackupObserver import android.content.Context import android.util.Log import android.util.Log.INFO import android.util.Log.isLoggable -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT -import androidx.core.app.NotificationCompat.PRIORITY_LOW - -private const val CHANNEL_ID = "NotificationBackupObserver" -private const val NOTIFICATION_ID = 1042 private val TAG = NotificationBackupObserver::class.java.simpleName -class NotificationBackupObserver( - private val context: Context, - private val userInitiated: Boolean) : IBackupObserver.Stub() { +class NotificationBackupObserver(context: Context, private val userInitiated: Boolean) : IBackupObserver.Stub() { private val pm = context.packageManager - private val nm = context.getSystemService(NotificationManager::class.java).apply { - val title = context.getString(R.string.notification_channel_title) - val channel = NotificationChannel(CHANNEL_ID, title, IMPORTANCE_LOW).apply { - enableVibration(false) - } - createNotificationChannel(channel) - } - private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID).apply { - setSmallIcon(R.drawable.ic_cloud_upload) - priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW - } + private val nm = (context.applicationContext as Backup).notificationManager /** * This method could be called several times for packages with full data backup. @@ -44,17 +22,13 @@ class NotificationBackupObserver( * @param backupProgress Current progress of backup for the package. */ override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) { - val transferred = backupProgress.bytesTransferred - val expected = backupProgress.bytesExpected + val transferred = backupProgress.bytesTransferred.toInt() + val expected = backupProgress.bytesExpected.toInt() if (isLoggable(TAG, INFO)) { Log.i(TAG, "Update. Target: $currentBackupPackage, $transferred/$expected") } - val notification = notificationBuilder.apply { - setContentTitle(context.getString(R.string.notification_title)) - setContentText(getAppName(currentBackupPackage)) - setProgress(expected.toInt(), transferred.toInt(), false) - }.build() - nm.notify(NOTIFICATION_ID, notification) + val app = getAppName(currentBackupPackage) + nm.onBackupUpdate(app, transferred, expected, userInitiated) } /** @@ -71,16 +45,7 @@ class NotificationBackupObserver( if (isLoggable(TAG, INFO)) { Log.i(TAG, "Completed. Target: $target, status: $status") } - val title = context.getString(when (status) { - 0 -> R.string.notification_backup_result_complete - TRANSPORT_PACKAGE_REJECTED -> R.string.notification_backup_result_rejected - else -> R.string.notification_backup_result_error - }) - val notification = notificationBuilder.apply { - setContentTitle(title) - setContentText(getAppName(target)) - }.build() - nm.notify(NOTIFICATION_ID, notification) + nm.onBackupResult(getAppName(target), status, userInitiated) } /** @@ -94,7 +59,7 @@ class NotificationBackupObserver( if (isLoggable(TAG, INFO)) { Log.i(TAG, "Backup finished. Status: $status") } - nm.cancel(NOTIFICATION_ID) + nm.onBackupFinished() } private fun getAppName(packageId: String): CharSequence { diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt index 18a46926..36f52c99 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsActivity.kt @@ -7,6 +7,7 @@ import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProviders +import com.stevesoltys.backup.Backup import com.stevesoltys.backup.LiveEventHandler import com.stevesoltys.backup.R @@ -25,8 +26,8 @@ class SettingsActivity : AppCompatActivity() { setContentView(R.layout.activity_settings) viewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java) - viewModel.onLocationSet.observeEvent(this, LiveEventHandler { wasEmptyBefore -> - if (wasEmptyBefore) showFragment(SettingsFragment()) + viewModel.onLocationSet.observeEvent(this, LiveEventHandler { initialSetUp -> + if (initialSetUp) showFragment(SettingsFragment()) else supportFragmentManager.popBackStack() }) viewModel.chooseBackupLocation.observeEvent(this, LiveEventHandler { show -> @@ -54,8 +55,10 @@ class SettingsActivity : AppCompatActivity() { // check that backup is provisioned if (!viewModel.recoveryCodeIsSet()) { showRecoveryCodeActivity() - } else if (!viewModel.locationIsSet()) { + } else if (!viewModel.validLocationIsSet()) { showFragment(BackupLocationFragment()) + // remove potential error notifications + (application as Backup).notificationManager.onBackupErrorSeen() } } diff --git a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt index 7ba92a1c..6840dabd 100644 --- a/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/backup/settings/SettingsViewModel.kt @@ -5,10 +5,12 @@ import android.content.Intent import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION import android.util.Log +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.AndroidViewModel import com.stevesoltys.backup.Backup import com.stevesoltys.backup.LiveEvent import com.stevesoltys.backup.MutableLiveEvent +import com.stevesoltys.backup.isOnExternalStorage import com.stevesoltys.backup.transport.ConfigurableBackupTransportService import com.stevesoltys.backup.transport.requestBackup @@ -30,7 +32,13 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true) fun recoveryCodeIsSet() = Backup.keyManager.hasBackupKey() - fun locationIsSet() = getBackupFolderUri(getApplication()) != null + + fun validLocationIsSet(): Boolean { + val uri = getBackupFolderUri(app) ?: return false + if (uri.isOnExternalStorage()) return true // might be a temporary failure + val file = DocumentFile.fromTreeUri(app, uri) ?: return false + return file.isDirectory + } fun handleChooseFolderResult(result: Intent?) { val folderUri = result?.data ?: return @@ -40,13 +48,13 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application app.contentResolver.takePersistableUriPermission(folderUri, takeFlags) // check if this is initial set-up or a later change - val wasEmptyBefore = getBackupFolderUri(app) == null + val initialSetUp = !validLocationIsSet() // store backup folder location in settings setBackupFolderUri(app, folderUri) // notify the UI that the location has been set - locationWasSet.setEvent(wasEmptyBefore) + locationWasSet.setEvent(initialSetUp) // stop backup service to be sure the old location will get updated app.stopService(Intent(app, ConfigurableBackupTransportService::class.java)) diff --git a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt index 1aeb85d4..0346049c 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/PluginManager.kt @@ -36,8 +36,9 @@ class PluginManager(context: Context) { private val inputFactory = InputFactory() private val kvBackup = KVBackup(backupPlugin.kvBackupPlugin, inputFactory, headerWriter, crypto) private val fullBackup = FullBackup(backupPlugin.fullBackupPlugin, inputFactory, headerWriter, crypto) + private val notificationManager = (context.applicationContext as Backup).notificationManager - internal val backupCoordinator = BackupCoordinator(backupPlugin, kvBackup, fullBackup) + internal val backupCoordinator = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager) private val restorePlugin = DocumentsProviderRestorePlugin(storage) diff --git a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt index 65ad91b0..a4d357f3 100644 --- a/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/backup/transport/backup/BackupCoordinator.kt @@ -5,6 +5,7 @@ import android.app.backup.BackupTransport.TRANSPORT_OK import android.content.pm.PackageInfo import android.os.ParcelFileDescriptor import android.util.Log +import com.stevesoltys.backup.BackupNotificationManager import java.io.IOException private val TAG = BackupCoordinator::class.java.simpleName @@ -16,7 +17,8 @@ private val TAG = BackupCoordinator::class.java.simpleName class BackupCoordinator( private val plugin: BackupPlugin, private val kv: KVBackup, - private val full: FullBackup) { + private val full: FullBackup, + private val nm: BackupNotificationManager) { private var calledInitialize = false private var calledClearBackupData = false @@ -53,6 +55,7 @@ class BackupCoordinator( TRANSPORT_OK } catch (e: IOException) { Log.e(TAG, "Error initializing device", e) + nm.onBackupError() TRANSPORT_ERROR } } diff --git a/app/src/main/res/drawable/ic_cloud_error.xml b/app/src/main/res/drawable/ic_cloud_error.xml new file mode 100644 index 00000000..ecb6ff7d --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud_error.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ac6ec6d..dce1cdd0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,4 +65,9 @@ Not backed up Backup failed + Error Notification + Backup Error + A device backup failed to run. + Fix + diff --git a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt index 7875db5e..2f9ceb3c 100644 --- a/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/backup/transport/CoordinatorIntegrationTest.kt @@ -7,6 +7,7 @@ import android.app.backup.BackupTransport.TRANSPORT_OK import android.app.backup.RestoreDescription import android.app.backup.RestoreDescription.TYPE_FULL_STREAM import android.os.ParcelFileDescriptor +import com.stevesoltys.backup.BackupNotificationManager import com.stevesoltys.backup.crypto.CipherFactoryImpl import com.stevesoltys.backup.crypto.CryptoImpl import com.stevesoltys.backup.crypto.KeyManagerTestImpl @@ -37,7 +38,8 @@ internal class CoordinatorIntegrationTest : TransportTest() { private val kvBackup = KVBackup(kvBackupPlugin, inputFactory, headerWriter, cryptoImpl) private val fullBackupPlugin = mockk() private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl) - private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup) + private val notificationManager = mockk() + private val backup = BackupCoordinator(backupPlugin, kvBackup, fullBackup, notificationManager) private val restorePlugin = mockk() private val kvRestorePlugin = mockk() diff --git a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt index e5833277..8e8b38fb 100644 --- a/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/backup/transport/backup/BackupCoordinatorTest.kt @@ -2,6 +2,7 @@ package com.stevesoltys.backup.transport.backup import android.app.backup.BackupTransport.TRANSPORT_ERROR import android.app.backup.BackupTransport.TRANSPORT_OK +import com.stevesoltys.backup.BackupNotificationManager import io.mockk.Runs import io.mockk.every import io.mockk.just @@ -17,8 +18,9 @@ internal class BackupCoordinatorTest: BackupTest() { private val plugin = mockk() private val kv = mockk() private val full = mockk() + private val notificationManager = mockk() - private val backup = BackupCoordinator(plugin, kv, full) + private val backup = BackupCoordinator(plugin, kv, full, notificationManager) @Test fun `device initialization succeeds and delegates to plugin`() { @@ -33,6 +35,7 @@ internal class BackupCoordinatorTest: BackupTest() { @Test fun `device initialization fails`() { every { plugin.initializeDevice() } throws IOException() + every { notificationManager.onBackupError() } just Runs assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) diff --git a/build.gradle b/build.gradle index 26dd7758..84707d92 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { - ext.kotlin_version = '1.3.41' + ext.kotlin_version = '1.3.50' repositories { jcenter()