Compare commits

...

81 commits

Author SHA1 Message Date
Pera Petrovic
87663a5235 Translated using Weblate (Serbian (latin))
Currently translated at 8.7% (10 of 114 strings)

Co-authored-by: Pera Petrovic <srdjan.todorovic.872019@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/sr_Latn/
Translation: CalyxOS/Seedvault
2020-11-26 00:07:44 +05:30
Milo Ivir
6855fa2c24 Translated using Weblate (Croatian)
Currently translated at 100.0% (114 of 114 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/hr/
Translation: CalyxOS/Seedvault
2020-11-26 00:07:44 +05:30
caioau
fed09d1193 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (114 of 114 strings)

Co-authored-by: caioau <caioau@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/pt_BR/
Translation: CalyxOS/Seedvault
2020-11-26 00:07:44 +05:30
Torsten Grote
447f1e5fb4 Move to different versioning scheme and show version in About dialog
Change-Id: I87004c05f9a54d3b2f7854695349e8b506fa7e44
2020-11-05 16:28:07 +05:30
Michael Bestas
63c2d2be11 Compatibility symlinks for weblate translations
* Map weblate locale code to AOSP locale

Change-Id: I9e03b48ca00d8446416681a48c1ad3c2f5f63c42
2020-11-04 18:55:44 +05:30
Michael Bestas
7aa96b53ab Fix broken translations
Change-Id: I5c3aa867cbc3952c1ea19b85bc3694476e69be7d
2020-11-04 18:55:44 +05:30
Chirayu Desai
2131f6bf20 Deleted translation using Weblate (Central Atlas Tamazight) 2020-11-04 18:19:54 +05:30
Chirayu Desai
6355a99c71 Deleted translation using Weblate (English (United States)) 2020-11-04 18:19:54 +05:30
Weblate
56256d3eeb Added translation using Weblate (Central Atlas Tamazight)
Added translation using Weblate (English (United States))

Co-authored-by: Weblate <noreply@weblate.org>
2020-11-04 18:19:54 +05:30
Chirayu Desai
5dcca5d62d Deleted translation using Weblate (Central Atlas Tamazight)
Deleted translation using Weblate (English (United States))

Co-authored-by: Chirayu Desai <chirayudesai1@gmail.com>
2020-11-04 18:19:54 +05:30
Óscar Fernández Díaz
1245985b50 Translated using Weblate (Spanish)
Currently translated at 100.0% (114 of 114 strings)

Co-authored-by: Óscar Fernández Díaz <oscfdezdz00@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/es/
Translation: CalyxOS/Seedvault
2020-11-04 18:19:54 +05:30
sock-et
f300a7a979 Translated using Weblate (Italian)
Currently translated at 60.5% (69 of 114 strings)

Co-authored-by: sock-et <inline.py@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/it/
Translation: CalyxOS/Seedvault
2020-11-04 18:19:54 +05:30
Yurical
6820ada3b3 Translated using Weblate (Korean)
Currently translated at 94.7% (108 of 114 strings)

Translated using Weblate (Korean)

Currently translated at 88.5% (101 of 114 strings)

Co-authored-by: Yurical <yurical1@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/ko/
Translation: CalyxOS/Seedvault
2020-11-04 18:19:54 +05:30
Eric
d42b350af3 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (114 of 114 strings)

Co-authored-by: Eric <spice2wolf@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/zh_Hans/
Translation: CalyxOS/Seedvault
2020-11-04 18:19:54 +05:30
tamer dab
0c576e724a Translated using Weblate (Arabic)
Currently translated at 8.7% (10 of 114 strings)

Translated using Weblate (Hebrew)

Currently translated at 100.0% (114 of 114 strings)

Co-authored-by: tamer dab <dabsantamer@yahoo.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/ar/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/he/
Translation: CalyxOS/Seedvault
2020-11-04 18:19:54 +05:30
LOyoujoLI
14b9feddf6 Translated using Weblate (Russian)
Currently translated at 96.4% (110 of 114 strings)

Co-authored-by: LOyoujoLI <minion2018@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/ru/
Translation: CalyxOS/Seedvault
2020-11-04 18:19:54 +05:30
Kyuzial
f2d375adec Translated using Weblate (French)
Currently translated at 99.1% (113 of 114 strings)

Co-authored-by: Kyuzial <kyuzial@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/fr/
Translation: CalyxOS/Seedvault
2020-11-04 18:19:54 +05:30
nautilusx
3f69269c5f Translated using Weblate (German)
Currently translated at 100.0% (114 of 114 strings)

Co-authored-by: nautilusx <mail.ka@mailbox.org>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/de/
Translation: CalyxOS/Seedvault
2020-11-04 18:19:54 +05:30
Nikita Epifanov
4169340f82 Translated using Weblate (Russian)
Currently translated at 94.7% (108 of 114 strings)

Co-authored-by: Nikita Epifanov <nikgreens@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/ru/
Translation: CalyxOS/Seedvault
2020-11-04 18:19:54 +05:30
Michael Bestas
ca27c6dcf7 Translated using Weblate (Greek)
Currently translated at 100.0% (114 of 114 strings)

Co-authored-by: Michael Bestas <mkbestas@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/el/
Translation: CalyxOS/Seedvault
2020-11-04 18:19:54 +05:30
Oğuz Ersen
80cb57d253 Translated using Weblate (Turkish)
Currently translated at 100.0% (114 of 114 strings)

Co-authored-by: Oğuz Ersen <oguzersen@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/tr/
Translation: CalyxOS/Seedvault
2020-11-04 18:19:54 +05:30
Weblate
87f9c8f96e Added translation using Weblate (English (United States))
Co-authored-by: Weblate <noreply@weblate.org>
2020-11-04 18:19:54 +05:30
Weblate
98e34a1eb3 Added translation using Weblate (Central Atlas Tamazight) 2020-10-22 04:08:21 +05:30
Atrate
47790d7556 Translated using Weblate (Polish)
Currently translated at 25.0% (28 of 112 strings)

Translation: CalyxOS/Seedvault
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/pl/
2020-10-22 04:08:21 +05:30
Allan Nordhøy
9d688ef9a4 Translated using Weblate (Norwegian Bokmål)
Currently translated at 78.5% (88 of 112 strings)

Translation: CalyxOS/Seedvault
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/nb_NO/
2020-10-22 04:08:21 +05:30
ssantos
c8cdb84877 Translated using Weblate (Portuguese)
Currently translated at 97.3% (109 of 112 strings)

Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/pt/
Translation: CalyxOS/Seedvault
2020-10-22 04:08:21 +05:30
Atrate
16ab41c4b6 Translated using Weblate (Polish)
Currently translated at 22.3% (25 of 112 strings)

Co-authored-by: Atrate <Atrate@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/pl/
Translation: CalyxOS/Seedvault
2020-10-22 04:08:21 +05:30
Milo Ivir
716fc31e1b Translated using Weblate (Croatian)
Currently translated at 100.0% (112 of 112 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/hr/
Translation: CalyxOS/Seedvault
2020-10-22 04:08:21 +05:30
Yaron Shahrabani
798b0beb1b Translated using Weblate (Hebrew)
Currently translated at 100.0% (112 of 112 strings)

Co-authored-by: Yaron Shahrabani <sh.yaron@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/he/
Translation: CalyxOS/Seedvault
Change-Id: Id58ce22954ae88f5856ae64879c7fbd56b2d1a62
2020-10-22 04:08:21 +05:30
Chirayu Desai
0246e2aad2 Translated using Weblate (French)
Currently translated at 80.3% (90 of 112 strings)

Co-authored-by: Chirayu Desai <chirayudesai1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/fr/
Translation: CalyxOS/Seedvault
2020-10-22 04:08:21 +05:30
Nikita Epifanov
5738b41a8e Translated using Weblate (Russian)
Currently translated at 100.0% (112 of 112 strings)

Translated using Weblate (Russian)

Currently translated at 98.2% (110 of 112 strings)

Co-authored-by: Nikita Epifanov <nikgreens@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/ru/
Translation: CalyxOS/Seedvault
2020-10-22 04:08:21 +05:30
Torsten Grote
bc999bb141 Fix storage chooser title if the translation is long 2020-10-22 04:08:21 +05:30
Torsten Grote
37a16ed42e Show proper transport labels for OS transport selection
If an AOSP-based ROM allows the user to choose a backup transport, these labels will be shown.
2020-10-22 04:08:21 +05:30
Torsten Grote
1ce2d199fa Fix lint issues with translations and ignore missing translations
as weblate doesn't seem to have a way to only import completed
translations.
2020-10-22 04:08:21 +05:30
Chirayu Desai
15de2da9d8 Fix translations manually, replacing &lt;&gt; by <>
* Previous attempt via weblate failed, let's just do it here this way
2020-10-22 03:22:49 +05:30
Hosted Weblate
932e414dff Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/
Translation: CalyxOS/Seedvault
2020-10-22 03:22:49 +05:30
Chirayu Desai
24cffd5a6e Translated using Weblate (French)
Currently translated at 80.3% (90 of 112 strings)

Translated using Weblate (Icelandic)

Currently translated at 81.8% (90 of 110 strings)

Translated using Weblate (Spanish (American))

Currently translated at 80.9% (89 of 110 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 78.1% (86 of 110 strings)

Translated using Weblate (French)

Currently translated at 81.8% (90 of 110 strings)

Co-authored-by: Chirayu Desai <chirayudesai1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/es_US/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/fr/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/is/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/nb_NO/
Translation: CalyxOS/Seedvault
2020-10-22 03:22:49 +05:30
ssantos
3bbd1b0fc5 Translated using Weblate (Portuguese)
Currently translated at 100.0% (110 of 110 strings)

Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/pt/
Translation: CalyxOS/Seedvault
2020-10-22 03:22:49 +05:30
Milo Ivir
915977551e Translated using Weblate (Croatian)
Currently translated at 100.0% (110 of 110 strings)

Co-authored-by: Milo Ivir <mail@milotype.de>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/hr/
Translation: CalyxOS/Seedvault
2020-10-22 03:22:49 +05:30
Michael Bestas
a3b6c9ac36 Translated using Weblate (Greek)
Currently translated at 100.0% (110 of 110 strings)

Co-authored-by: Michael Bestas <mkbestas@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/el/
Translation: CalyxOS/Seedvault
2020-10-22 03:22:49 +05:30
H
10fa0e8039 Translated using Weblate (Spanish)
Currently translated at 99.0% (109 of 110 strings)

Co-authored-by: H <joaquinfc@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/es/
Translation: CalyxOS/Seedvault
2020-10-22 03:22:49 +05:30
Nikita Epifanov
4379b01235 Translated using Weblate (Russian)
Currently translated at 100.0% (110 of 110 strings)

Co-authored-by: Nikita Epifanov <nikgreens@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/ru/
Translation: CalyxOS/Seedvault
2020-10-22 03:22:49 +05:30
Samuel Carvalho de Araújo
adcdc70761 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (110 of 110 strings)

Co-authored-by: Samuel Carvalho de Araújo <samuelnegro12345@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/pt_BR/
Translation: CalyxOS/Seedvault
2020-10-22 03:22:49 +05:30
Hosted Weblate
78d7966d56 Update translation files
Updated by "Cleanup translation files" hook in Weblate.

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (108 of 108 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 79.6% (86 of 108 strings)

Translated using Weblate (Spanish)

Currently translated at 99.0% (107 of 108 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 72.2% (78 of 108 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (108 of 108 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 14.8% (16 of 108 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 94.4% (102 of 108 strings)

Translated using Weblate (Russian)

Currently translated at 7.4% (8 of 108 strings)

Translated using Weblate (Russian)

Currently translated at 6.4% (7 of 108 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 3.7% (4 of 108 strings)

Translated using Weblate (German)

Currently translated at 70.3% (76 of 108 strings)

Translated using Weblate (Icelandic)

Currently translated at 100.0% (108 of 108 strings)

Translated using Weblate (Spanish (American))

Currently translated at 97.2% (105 of 108 strings)

Translated using Weblate (Spanish)

Currently translated at 34.2% (37 of 108 strings)

Translated using Weblate (Italian)

Currently translated at 60.1% (65 of 108 strings)

Translated using Weblate (Italian)

Currently translated at 38.8% (42 of 108 strings)

Translated using Weblate (Norwegian Bokmål)

Currently translated at 94.4% (102 of 108 strings)

Translated using Weblate (German)

Currently translated at 10.1% (11 of 108 strings)

Translated using Weblate (French)

Currently translated at 100.0% (108 of 108 strings)

Translated using Weblate (French)

Currently translated at 65.7% (71 of 108 strings)

Added translation using Weblate (Dutch)

Added translation using Weblate (Zulu)

Added translation using Weblate (Chinese (Traditional, Hong Kong))

Added translation using Weblate (Chinese (Traditional))

Added translation using Weblate (Chinese (Simplified))

Added translation using Weblate (Vietnamese)

Added translation using Weblate (Uzbek)

Added translation using Weblate (Urdu)

Added translation using Weblate (Ukrainian)

Added translation using Weblate (Turkish)

Added translation using Weblate (Tagalog)

Added translation using Weblate (Thai)

Added translation using Weblate (Telugu)

Added translation using Weblate (Tamil)

Added translation using Weblate (Swahili)

Added translation using Weblate (Swedish)

Added translation using Weblate (Serbian (latin))

Added translation using Weblate (Serbian)

Added translation using Weblate (Albanian)

Added translation using Weblate (Slovenian)

Added translation using Weblate (Slovak)

Added translation using Weblate (Sinhala)

Added translation using Weblate (Romanian)

Added translation using Weblate (Portuguese (Portugal))

Added translation using Weblate (Portuguese (Brazil))

Added translation using Weblate (Portuguese)

Added translation using Weblate (Polish)

Added translation using Weblate (Punjabi)

Added translation using Weblate (Odia)

Added translation using Weblate (Nepali)

Added translation using Weblate (Burmese)

Added translation using Weblate (Malay)

Added translation using Weblate (Marathi)

Added translation using Weblate (Mongolian)

Added translation using Weblate (Malayalam)

Added translation using Weblate (Macedonian)

Added translation using Weblate (Latvian)

Added translation using Weblate (Lithuanian)

Added translation using Weblate (Lao)

Added translation using Weblate (Kyrgyz)

Added translation using Weblate (Korean)

Added translation using Weblate (Kannada)

Added translation using Weblate (Central Khmer)

Added translation using Weblate (Kazakh)

Added translation using Weblate (Georgian)

Added translation using Weblate (Japanese)

Added translation using Weblate (Icelandic)

Added translation using Weblate (Indonesian)

Added translation using Weblate (Armenian)

Added translation using Weblate (Hungarian)

Added translation using Weblate (Croatian)

Added translation using Weblate (Galician)

Added translation using Weblate (French (Canada))

Added translation using Weblate (Finnish)

Added translation using Weblate (Persian)

Added translation using Weblate (Basque)

Added translation using Weblate (Estonian)

Added translation using Weblate (English (India))

Added translation using Weblate (English (United Kingdom))

Added translation using Weblate (English (Canada))

Added translation using Weblate (English (Australia))

Added translation using Weblate (Danish)

Added translation using Weblate (Czech)

Added translation using Weblate (Catalan)

Added translation using Weblate (Bosnian)

Added translation using Weblate (Bengali)

Added translation using Weblate (Bulgarian)

Added translation using Weblate (Belarusian)

Added translation using Weblate (Azerbaijani)

Added translation using Weblate (Assamese)

Added translation using Weblate (Amharic)

Added translation using Weblate (Afrikaans)

Added translation using Weblate (Spanish (American))

Added translation using Weblate (Spanish)

Added translation using Weblate (Arabic)

Added translation using Weblate (Italian)

Added translation using Weblate (Hebrew)

Added translation using Weblate (Norwegian Bokmål)

Added translation using Weblate (Hindi)

Added translation using Weblate (Russian)

Added translation using Weblate (German)

Added translation using Weblate (Gujarati)

Added translation using Weblate (French)

Translated using Weblate (Greek)

Currently translated at 29.6% (32 of 108 strings)

Added translation using Weblate (Greek)

Co-authored-by: Adolfo Jayme Barrientos <fitojb@ubuntu.com>
Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: Catherine Pierattini <catherine.pierattini@gmail.com>
Co-authored-by: CatieC <catie@calyxinstitute.org>
Co-authored-by: Chirayu Desai <chirayudesai1@gmail.com>
Co-authored-by: Daniel <dan.ef1999@gmail.com>
Co-authored-by: H <joaquinfc@protonmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Meili Huang <meilihuang1216@gmail.com>
Co-authored-by: Michael Bestas <mkbestas@gmail.com>
Co-authored-by: Mordur Aslaugarson <mordur@1984.is>
Co-authored-by: Nikita Epifanov <nikgreens@protonmail.com>
Co-authored-by: Robin Kunze <robinkunze@outlook.com>
Co-authored-by: Samuel Carvalho de Araújo <samuelnegro12345@gmail.com>
Co-authored-by: Santiago Cruz <scruz4@tuta.io>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/de/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/el/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/es/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/es_US/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/fr/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/is/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/it/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/ru/
Translate-URL: https://hosted.weblate.org/projects/calyxos/seedvault/zh_Hans/
Translation: CalyxOS/Seedvault
2020-10-22 03:22:49 +05:30
Torsten Grote
b974c31515 Always show Nextcloud as an option, offer to install or set up account
Outside of SetupWizard restore, we don't offer to set up an account,
because we don't know if one already exists and the app was locked with
a passcode.
2020-10-22 03:22:49 +05:30
Torsten Grote
4b72cf87ec Fix status reporting of failed system app restore 2020-10-22 03:22:49 +05:30
Torsten Grote
06c9642a22 User-initiated backups should also be incremental 2020-10-22 03:22:49 +05:30
Torsten Grote
db0721cd8d Bring the user to app system settings when long tapping apps 2020-10-22 03:22:49 +05:30
Torsten Grote
9f85a66235 Show a different text for stopped apps in app backups status page 2020-10-22 03:22:49 +05:30
Torsten Grote
965431149e Treat stopped apps different from opt-out apps
Apps that have FLAG_STOPPED will not get backed up, just like apps
without flag ALLOW_BACKUP will not get backed up.
In the UI both cases are shown the same way: app does not allow backup
This can be confusing for the user as it is not true for stopped apps.
Therefore, this commit introduces a new stopped state for apps,
so we can differentiate between both cases.
2020-10-22 03:22:49 +05:30
Torsten Grote
397f27b460 Fix opt-out apps showing up as not yet backed up
This bug also caused APKs of opt-out apps not getting backed up.
2020-10-22 03:22:49 +05:30
Torsten Grote
1e3263ec54 Fix bug where we could not do two subsequent restores
This probably never showed in practice, but it can be triggered easily
when testing with `adb shell bmgr restore`.
2020-10-22 03:22:49 +05:30
Torsten Grote
5f771ff4ec Fix auto-service warning in instrumentation tests 2020-10-22 03:22:49 +05:30
Torsten Grote
fa617fbaae Don't use Kotlin reflection if not really necessary 2020-10-22 03:22:49 +05:30
Torsten Grote
15969e0d88 Cache folder contents in K/V backup/restore
This speeds up things significantly and was needed due to poor
performance of call log backup.
2020-10-22 03:22:49 +05:30
Chirayu Desai
02438c91d3 Drop prebuilt deployment
* With Android.bp support merged in (and working well for both 10 and 11),
  we keep that as the primary and supported method to integrate Seedvault.
* Gradle can still be used for development
* You can still use your own prebuilts if you want, we just won't be
  putting them out ourselves
2020-10-22 03:22:49 +05:30
Michael Bestas
fac1eada12 Make seedvault compile in AOSP properly
* Add Android.bp to compile using AOSP build system instead of gradle
* Add prebuilt external libs that are not available on AOSP

Fixes #97

Co-authored-by: Chirayu Desai <chirayudesai1@gmail.com>
2020-10-22 03:22:49 +05:30
Michael Bestas
e9fd97c41e Upgrade androidx-lifecycle-livedata to 2.3.0-alpha05
* All previous aar versions have their modified date set to 0 which trigger
  the following openjdk bug:
  https://bugs.openjdk.java.net/browse/JDK-8184940
  This fixes the following compilation error while building in AOSP environment:

    java.time.DateTimeException: Invalid value for MonthOfYear (valid values 1 - 12): 0
2020-10-22 03:22:49 +05:30
Michael Bestas
0ce613b64d Drop dependency on kotlin-android-extensions
* Not available on AOSP.
2020-10-22 03:22:49 +05:30
Torsten Grote
b12adcd4c0 Don't use BuildConfig, because it is only available for gradle builds
and not in AOSP builds which will break
2020-10-22 03:22:49 +05:30
Torsten Grote
30e70527fb Don't let the user start a new backup when one is already in progress 2020-10-22 03:22:49 +05:30
Torsten Grote
b9ffe2c03e Show notification for backup running in the background
The system triggers backup jobs periodically or when a package is
announcing that its data has changed. So far we were not showing
notifications for those. This commit shows a notification with an
indeterminate progress bar as we don't have any information about how
many packages will get backed up.
2020-10-22 03:22:49 +05:30
Torsten Grote
72871d3d66 Enable backup of call logs
It turned out that call log backup is already in AOSP, but it is
disabled by an undocumented flag. This commit sets this flag (for new
and existing installs) to enable call log backup.
2020-10-22 03:22:49 +05:30
Torsten Grote
425459fe79 When restoring, set token from RestoreSet as new token 2020-10-22 03:22:49 +05:30
Torsten Grote
f6ea5c1db5 Clean up backup transport initialization logic
This commit makes creating new RestoreSets explicit.
Initializing a backup transport now actually cleans its data as the AOSP
documentation demands. This should be fine as we usually do a fresh
backup after a new initialization.
Contrary to before, an initialization does not create new RestoreSets
anymore, but works within the existing set. For now, only manually
choosing a new storage location creates a new RestoreSet.
2020-10-22 03:22:49 +05:30
Torsten Grote
a425ae706e Show percentages in progress notification and x of n status at the end
Fine-grained progress reporting causes apps to show up twice which is
confusing. Also @pm@ metadata and opt-out APKs are too much detail for
normal users. So we decided to only show a percentage in the progress
notification.

When the backup finished, the app now shows "x of n apps backed up"
which is more positive when the previous negative message of how many
apps were not backed up.

Some further minor tweets were done to app counting to report proper
totals.
2020-10-22 03:22:49 +05:30
Torsten Grote
d2c426db93 Let backup notification report more fine-grained progress
This adds @pm@ record backup and APK backup of opt-out apps to the
progress reporting since these two operations are slow when using a
cloud storage SAF backend.
2020-10-22 03:22:49 +05:30
Torsten Grote
740fe53a52 Improve DocumentsProvider tests against Nextcloud 2020-10-22 03:22:49 +05:30
Torsten Grote
897fd8473e Explain better how we force initialization via the SettingsManager 2020-10-22 03:22:49 +05:30
Torsten Grote
77ce3f6fe8 Make app blacklist accessible by multiple threads
Might fix #83
2020-10-22 03:22:49 +05:30
Torsten Grote
0b6742df44 Only consider apps that really opt-out of backup for early APK backup 2020-10-22 03:22:49 +05:30
Torsten Grote
a63a893a61 Ensure streams get closed eventually 2020-10-22 03:22:49 +05:30
Torsten Grote
5515e5c88f Fix icon color of storage locations (Nextcloud icon got tinted) 2020-10-22 03:22:49 +05:30
Torsten Grote
30e66f368e Make PluginTest work for Nextcloud as well
Only issue left was a different maximum file name length for Nextcloud
2020-10-22 03:22:49 +05:30
Torsten Grote
2958c8fac8 Replace all instances of DocumentFile#findFile with #findFileBlocking
Also start sticking closer to the official Kotlin formatting style
2020-10-22 03:22:49 +05:30
Torsten Grote
18d83767b3 Check for loading cursor also when checking if files exist
Loading cursors can happen with cloud-based documents providers
such as Nextcloud.
When they return a cursor that is still loading,
we might continue with stale information.
So now we wait for a loading cursor to be fully loaded
before continuing.
2020-10-22 03:22:49 +05:30
Torsten Grote
131c5b6b29 Add test to reproduce the loading cursor phenomena with Nextcloud
See: https://commonsware.com/blog/2019/12/14/scoped-storage-stories-listfiles-woe.html
2020-10-22 03:22:49 +05:30
Torsten Grote
22aaaeb1fd Add instrumentation tests for storage plugin (SAF) 2020-10-22 03:22:49 +05:30
Torsten Grote
2f62e9515c Upgrade gradle 2020-10-22 03:22:49 +05:30
Michael Bestas
b563893304 String improvements
* Don't use camel case, following AOSP applications
* Set app name and Nextcloud as untranslatable
2020-10-22 03:22:49 +05:30
Robin Schneider
f5f341b7b7 Remove wrongly inserted "g" char from AndroidManifest.xml
Introduced in: 78e217c7d8
2020-10-22 03:22:49 +05:30
194 changed files with 5341 additions and 1162 deletions

3
.gitignore vendored
View file

@ -7,7 +7,8 @@ hs_err_pid*
## Intellij ## Intellij
out/ out/
lib/ lib/
.idea/ .idea/*
!.idea/runConfigurations*
*.ipr *.ipr
*.iws *.iws
*.iml *.iml

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
</set>
</option>
</component>
</project>

View file

@ -0,0 +1,48 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Instrumentation Tests" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
<module name="app" />
<option name="TESTING_TYPE" value="0" />
<option name="METHOD_NAME" value="" />
<option name="CLASS_NAME" value="" />
<option name="PACKAGE_NAME" value="" />
<option name="INSTRUMENTATION_RUNNER_CLASS" value="" />
<option name="EXTRA_OPTIONS" value="-e notAnnotation androidx.test.filters.LargeTest" />
<option name="INCLUDE_GRADLE_EXTRA_OPTIONS" value="true" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
<option name="FORCE_STOP_RUNNING_APP" value="true" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="DEBUGGER_TYPE" value="Auto" />
<Auto>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Auto>
<Hybrid>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Hybrid>
<Java />
<Native>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Native>
<Profilers>
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Sample Java Methods" />
</Profilers>
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

View file

@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Unit Tests" type="AndroidJUnit" factoryName="Android JUnit">
<module name="app" />
<useClassPathOnly />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
<option name="ALTERNATIVE_JRE_PATH" value="/usr/lib/jvm/java-11" />
<option name="MAIN_CLASS_NAME" value="" />
<option name="METHOD_NAME" value="" />
<option name="TEST_OBJECT" value="directory" />
<option name="PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$MODULE_DIR$" />
<dir value="$PROJECT_DIR$/app/src/test/java/com/stevesoltys/seedvault" />
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

View file

@ -31,12 +31,3 @@ cache:
- $HOME/.gradle/caches/ - $HOME/.gradle/caches/
- $HOME/.gradle/wrapper/ - $HOME/.gradle/wrapper/
- $HOME/.android/build-cache - $HOME/.android/build-cache
deploy:
provider: script
script: ./deploy-prebuilt.sh
skip_cleanup: true
on:
repo: stevesoltys/seedvault
all_branches: true
condition: $TRAVIS_BRANCH =~ ^(master|develop)$

69
Android.bp Normal file
View file

@ -0,0 +1,69 @@
//
// Copyright (C) 2018 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
android_app {
name: "Seedvault",
srcs: [
"app/src/main/java/**/*.kt",
"app/src/main/java/**/*.java",
],
resource_dirs: [
"app/src/main/res",
],
static_libs: [
"com.google.android.material_material",
"androidx.core_core",
"androidx.preference_preference",
"androidx.lifecycle_lifecycle-extensions",
"androidx-constraintlayout_constraintlayout",
"seedvault-lib-androidx-core-ktx",
"seedvault-lib-androidx-lifecycle-livedata-core-ktx",
"seedvault-lib-androidx-lifecycle-livedata-ktx",
"seedvault-lib-androidx-lifecycle-viewmodel-ktx",
"seedvault-lib-koin-android",
"seedvault-lib-koin-androidx-viewmodel",
"seedvault-lib-commons-io",
"seedvault-lib-koin-core",
"seedvault-lib-kotlinx-coroutines-android",
"seedvault-lib-kotlinx-coroutines-core",
"seedvault-lib-novacrypto-bip39",
"seedvault-lib-novacrypto-sha256",
"seedvault-lib-novacrypto-toruntime"
],
manifest: "app/src/main/AndroidManifest.xml",
platform_apis: true,
certificate: "platform",
privileged: true,
required: [
"privapp_whitelist_com.stevesoltys.backup",
"com.stevesoltys.backup_whitelist"
]
}
prebuilt_etc {
name: "privapp_whitelist_com.stevesoltys.backup",
sub_dir: "permissions",
src: "permissions_com.stevesoltys.seedvault.xml",
filename_from_src: true,
}
prebuilt_etc {
name: "com.stevesoltys.backup_whitelist",
sub_dir: "sysconfig",
src: "whitelist_com.stevesoltys.seedvault.xml",
filename_from_src: true,
}

View file

@ -1,27 +0,0 @@
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := permissions_com.stevesoltys.seedvault.xml
LOCAL_MODULE_CLASS := ETC
LOCAL_MODULE_TAGS := optional
LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/permissions
LOCAL_SRC_FILES := $(LOCAL_MODULE)
include $(BUILD_PREBUILT)
include $(CLEAR_VARS)
LOCAL_MODULE := whitelist_com.stevesoltys.seedvault.xml
LOCAL_MODULE_CLASS := ETC
LOCAL_MODULE_TAGS := optional
LOCAL_MODULE_PATH := $(TARGET_OUT_ETC)/sysconfig
LOCAL_SRC_FILES := $(LOCAL_MODULE)
include $(BUILD_PREBUILT)
include $(CLEAR_VARS)
LOCAL_MODULE := Seedvault
LOCAL_SRC_FILES := Seedvault.apk
LOCAL_CERTIFICATE := platform
LOCAL_MODULE_CLASS := APPS
LOCAL_PRIVILEGED_MODULE := true
LOCAL_DEX_PREOPT := false
LOCAL_REQUIRED_MODULES := permissions_com.stevesoltys.seedvault.xml whitelist_com.stevesoltys.seedvault.xml
include $(BUILD_PREBUILT)

View file

@ -24,7 +24,7 @@ It uses the same internal APIs as `adb backup` which is deprecated and thus need
* `android.permission.BACKUP` to back up application data. * `android.permission.BACKUP` to back up application data.
* `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots. * `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots.
* `android.permission.MANAGE_USB` to access the serial number of USB mass storage devices. * `android.permission.MANAGE_USB` to access the serial number of USB mass storage devices.
* `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings. * `android.permission.WRITE_SECURE_SETTINGS` to change system backup settings and enable call log backup.
* `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from backup. * `android.permission.INSTALL_PACKAGES` to re-install apps when restoring from backup.
## Contributing ## Contributing

View file

@ -2,16 +2,25 @@ import groovy.xml.XmlUtil
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
def gitDescribe = { ->
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'describe', '--always', '--tags', '--dirty=-dirty'
standardOutput = stdout
}
return stdout.toString().trim()
}
android { android {
compileSdkVersion 29 compileSdkVersion 29
buildToolsVersion '29.0.2' buildToolsVersion '29.0.2' // adapt in .travis.yaml if changed here
defaultConfig { defaultConfig {
minSdkVersion 29 minSdkVersion 29
targetSdkVersion 29 targetSdkVersion 29
versionNameSuffix "-$gitDescribe"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments disableAnalytics: 'true' testInstrumentationRunnerArguments disableAnalytics: 'true'
} }
@ -109,6 +118,9 @@ def aospDeps = fileTree(include: [
'libcore.jar' 'libcore.jar'
], dir: 'libs') ], dir: 'libs')
// If the dependencies below are updated please make sure to update the
// prebuilt libraries and Android.bp in the top `libs` folder to reflect that.
// You can copy these libraries from ~/.gradle/caches/modules-2
dependencies { dependencies {
compileOnly aospDeps compileOnly aospDeps
@ -123,20 +135,26 @@ dependencies {
implementation 'com.google.android.material:material:1.0.0' implementation 'com.google.android.material:material:1.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-rc03' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-alpha05'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha' lintChecks 'com.github.thirdegg:lint-rules:0.0.5-alpha'
def junit_version = "5.5.2" def junit_version = "5.5.2" // careful, upgrading this can change a Cipher's IV size in tests!?
testImplementation aospDeps def mockk_version = "1.10.0"
testImplementation aospDeps // anything less fails tests run with gradlew
testImplementation 'androidx.test.ext:junit:1.1.1' testImplementation 'androidx.test.ext:junit:1.1.1'
testImplementation 'org.robolectric:robolectric:4.3.1' testImplementation('org.robolectric:robolectric:4.3.1') {
// https://github.com/robolectric/robolectric/issues/5245
exclude group: "com.google.auto.service", module: "auto-service"
}
testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version" testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"
testImplementation 'io.mockk:mockk:1.9.3' testImplementation "io.mockk:mockk:$mockk_version"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version"
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit_version" testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit_version"
androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0' androidTestImplementation 'androidx.test:rules:1.2.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation "io.mockk:mockk-android:$mockk_version"
} }

4
app/lint.xml Normal file
View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<issue id="MissingTranslation" severity="ignore" />
</lint>

View file

@ -1,8 +1,8 @@
package com.stevesoltys.seedvault package com.stevesoltys.seedvault
import android.util.Log import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import androidx.test.runner.AndroidJUnit4
import com.stevesoltys.seedvault.crypto.CipherFactoryImpl import com.stevesoltys.seedvault.crypto.CipherFactoryImpl
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue

View file

@ -1,73 +0,0 @@
package com.stevesoltys.seedvault
import androidx.documentfile.provider.DocumentFile
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.AndroidJUnit4
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.plugins.saf.createOrGetFile
import com.stevesoltys.seedvault.settings.SettingsManager
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 org.koin.core.KoinComponent
import org.koin.core.inject
import kotlin.random.Random
private const val filename = "test-file"
@RunWith(AndroidJUnit4::class)
class DocumentsStorageTest : KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val metadataManager by inject<MetadataManager>()
private val settingsManager by inject<SettingsManager>()
private val storage = DocumentsStorage(context, metadataManager, settingsManager)
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)
}
}

View file

@ -0,0 +1,349 @@
package com.stevesoltys.seedvault
import androidx.test.core.content.pm.PackageInfoBuilder
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderBackupPlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderFullBackup
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderFullRestorePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderKVBackup
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderKVRestorePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderRestorePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.plugins.saf.MAX_KEY_LENGTH
import com.stevesoltys.seedvault.plugins.saf.MAX_KEY_LENGTH_NEXTCLOUD
import com.stevesoltys.seedvault.plugins.saf.deleteContents
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.KoinComponent
import org.koin.core.inject
import java.io.IOException
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
@Suppress("BlockingMethodInNonBlockingContext")
class PluginTest : KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val settingsManager: SettingsManager by inject()
private val mockedSettingsManager: SettingsManager = mockk()
private val storage = DocumentsStorage(context, mockedSettingsManager)
private val kvBackupPlugin: KVBackupPlugin = DocumentsProviderKVBackup(context, storage)
private val fullBackupPlugin: FullBackupPlugin = DocumentsProviderFullBackup(context, storage)
private val backupPlugin: BackupPlugin = DocumentsProviderBackupPlugin(
context,
storage,
kvBackupPlugin,
fullBackupPlugin
)
private val kvRestorePlugin: KVRestorePlugin =
DocumentsProviderKVRestorePlugin(context, storage)
private val fullRestorePlugin: FullRestorePlugin =
DocumentsProviderFullRestorePlugin(context, storage)
private val restorePlugin: RestorePlugin =
DocumentsProviderRestorePlugin(context, storage, kvRestorePlugin, fullRestorePlugin)
private val token = Random.nextLong()
private val packageInfo = PackageInfoBuilder.newBuilder().setPackageName("org.example").build()
private val packageInfo2 = PackageInfoBuilder.newBuilder().setPackageName("net.example").build()
@Before
fun setup() = runBlocking {
every { mockedSettingsManager.getStorage() } returns settingsManager.getStorage()
storage.rootBackupDir?.deleteContents(context)
?: error("Select a storage location in the app first!")
}
@After
fun tearDown() = runBlocking {
storage.rootBackupDir?.deleteContents(context)
Unit
}
@Test
fun testProviderPackageName() {
assertNotNull(backupPlugin.providerPackageName)
}
/**
* This test initializes the storage three times while creating two new restore sets.
*
* If this is run against a Nextcloud storage backend,
* it has a high chance of getting a loading cursor in the underlying queries
* that needs to get re-queried to get real results.
*/
@Test
fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) {
// no backups available initially
assertEquals(0, restorePlugin.getAvailableBackups()?.toList()?.size)
val uri = settingsManager.getStorage()?.getDocumentFile(context)?.uri ?: error("no storage")
assertFalse(restorePlugin.hasBackup(uri))
// prepare returned tokens requested when initializing device
every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1)
// start new restore set and initialize device afterwards
backupPlugin.startNewRestoreSet(token)
backupPlugin.initializeDevice()
// write metadata (needed for backup to be recognized)
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
// one backup available now
assertEquals(1, restorePlugin.getAvailableBackups()?.toList()?.size)
assertTrue(restorePlugin.hasBackup(uri))
// initializing again (with another restore set) does add a restore set
backupPlugin.startNewRestoreSet(token + 1)
backupPlugin.initializeDevice()
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size)
assertTrue(restorePlugin.hasBackup(uri))
// initializing again (without new restore set) doesn't change number of restore sets
backupPlugin.initializeDevice()
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size)
// ensure that the new backup dirs exist
assertTrue(storage.currentKvBackupDir!!.exists())
assertTrue(storage.currentFullBackupDir!!.exists())
}
@Test
fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) {
every { mockedSettingsManager.getToken() } returns token
backupPlugin.startNewRestoreSet(token)
backupPlugin.initializeDevice()
// write metadata
val metadata = getRandomByteArray()
backupPlugin.getMetadataOutputStream().writeAndClose(metadata)
// get available backups, expect only one with our token and no error
var availableBackups = restorePlugin.getAvailableBackups()?.toList()
check(availableBackups != null)
assertEquals(1, availableBackups.size)
assertEquals(token, availableBackups[0].token)
assertFalse(availableBackups[0].error)
// read metadata matches what was written earlier
assertReadEquals(metadata, availableBackups[0].inputStream)
// initializing again (without changing storage) keeps restore set with same token
backupPlugin.initializeDevice()
backupPlugin.getMetadataOutputStream().writeAndClose(metadata)
availableBackups = restorePlugin.getAvailableBackups()?.toList()
check(availableBackups != null)
assertEquals(1, availableBackups.size)
assertEquals(token, availableBackups[0].token)
assertFalse(availableBackups[0].error)
// metadata hasn't changed
assertReadEquals(metadata, availableBackups[0].inputStream)
}
@Test
fun testApkWriteRead() = runBlocking {
// initialize storage with given token
initStorage(token)
// write random bytes as APK
val apk1 = getRandomByteArray(1337 * 1024)
backupPlugin.getApkOutputStream(packageInfo).writeAndClose(apk1)
// assert that read APK bytes match what was written
assertReadEquals(apk1, restorePlugin.getApkInputStream(token, packageInfo.packageName))
// write random bytes as another APK
val apk2 = getRandomByteArray(23 * 1024 * 1024)
backupPlugin.getApkOutputStream(packageInfo2).writeAndClose(apk2)
// assert that read APK bytes match what was written
assertReadEquals(apk2, restorePlugin.getApkInputStream(token, packageInfo2.packageName))
}
@Test
fun testKvBackupRestore() = runBlocking {
// define shortcuts
val kvBackup = backupPlugin.kvBackupPlugin
val kvRestore = restorePlugin.kvRestorePlugin
// initialize storage with given token
initStorage(token)
// no data available for given package
assertFalse(kvBackup.hasDataForPackage(packageInfo))
assertFalse(kvRestore.hasDataForPackage(token, packageInfo))
// define key/value pair records
val record1 = Pair(getRandomBase64(23), getRandomByteArray(1337))
val record2 = Pair(getRandomBase64(42), getRandomByteArray(42 * 1024))
val record3 = Pair(getRandomBase64(128), getRandomByteArray(5 * 1024 * 1024))
// write first record
kvBackup.getOutputStreamForRecord(packageInfo, record1.first).writeAndClose(record1.second)
// data is now available for current token and given package, but not for different token
assertTrue(kvBackup.hasDataForPackage(packageInfo))
assertTrue(kvRestore.hasDataForPackage(token, packageInfo))
assertFalse(kvRestore.hasDataForPackage(token + 1, packageInfo))
// record for package is found and returned properly
var records = kvRestore.listRecords(token, packageInfo)
assertEquals(1, records.size)
assertEquals(record1.first, records[0])
assertReadEquals(
record1.second,
kvRestore.getInputStreamForRecord(token, packageInfo, record1.first)
)
// write second and third record
kvBackup.getOutputStreamForRecord(packageInfo, record2.first).writeAndClose(record2.second)
kvBackup.getOutputStreamForRecord(packageInfo, record3.first).writeAndClose(record3.second)
// all records for package are found and returned properly
assertTrue(kvRestore.hasDataForPackage(token, packageInfo))
records = kvRestore.listRecords(token, packageInfo)
assertEquals(listOf(record1.first, record2.first, record3.first).sorted(), records.sorted())
assertReadEquals(
record1.second,
kvRestore.getInputStreamForRecord(token, packageInfo, record1.first)
)
assertReadEquals(
record2.second,
kvRestore.getInputStreamForRecord(token, packageInfo, record2.first)
)
assertReadEquals(
record3.second,
kvRestore.getInputStreamForRecord(token, packageInfo, record3.first)
)
// delete record3 and ensure that the other two are still found
kvBackup.deleteRecord(packageInfo, record3.first)
assertTrue(kvRestore.hasDataForPackage(token, packageInfo))
records = kvRestore.listRecords(token, packageInfo)
assertEquals(listOf(record1.first, record2.first).sorted(), records.sorted())
// remove all data of package and ensure that it is gone
kvBackup.removeDataOfPackage(packageInfo)
assertFalse(kvBackup.hasDataForPackage(packageInfo))
assertFalse(kvRestore.hasDataForPackage(token, packageInfo))
}
@Test
fun testMaxKvKeyLength() = runBlocking {
// define shortcuts
val kvBackup = backupPlugin.kvBackupPlugin
val kvRestore = restorePlugin.kvRestorePlugin
// initialize storage with given token
initStorage(token)
assertFalse(kvBackup.hasDataForPackage(packageInfo))
// FIXME get Nextcloud to have the same limit
// Since Nextcloud is using WebDAV and that seems to have undefined lower file name limits
// we might have to lower our maximum to accommodate for that.
val max = if (isNextcloud()) MAX_KEY_LENGTH_NEXTCLOUD else MAX_KEY_LENGTH
val maxOver = if (isNextcloud()) max + 10 else max + 1
// define record with maximum key length and one above the maximum
val recordMax = Pair(getRandomBase64(max), getRandomByteArray(1024))
val recordOver = Pair(getRandomBase64(maxOver), getRandomByteArray(1024))
// write max record
kvBackup.getOutputStreamForRecord(packageInfo, recordMax.first)
.writeAndClose(recordMax.second)
// max record is found correctly
assertTrue(kvRestore.hasDataForPackage(token, packageInfo))
val records = kvRestore.listRecords(token, packageInfo)
assertEquals(listOf(recordMax.first), records)
// write exceeding key length record
if (isNextcloud()) {
// Nextcloud simply refuses to write long filenames
coAssertThrows(IOException::class.java) {
kvBackup.getOutputStreamForRecord(packageInfo, recordOver.first)
.writeAndClose(recordOver.second)
}
} else {
coAssertThrows(IllegalStateException::class.java) {
kvBackup.getOutputStreamForRecord(packageInfo, recordOver.first)
.writeAndClose(recordOver.second)
}
}
}
@Test
fun testFullBackupRestore() = runBlocking {
// define shortcuts
val fullBackup = backupPlugin.fullBackupPlugin
val fullRestore = restorePlugin.fullRestorePlugin
// initialize storage with given token
initStorage(token)
// no data available initially
assertFalse(fullRestore.hasDataForPackage(token, packageInfo))
assertFalse(fullRestore.hasDataForPackage(token, packageInfo2))
// write full backup data
val data = getRandomByteArray(5 * 1024 * 1024)
fullBackup.getOutputStream(packageInfo).writeAndClose(data)
// data is available now, but only this token
assertTrue(fullRestore.hasDataForPackage(token, packageInfo))
assertFalse(fullRestore.hasDataForPackage(token + 1, packageInfo))
// restore data matches backed up data
assertReadEquals(data, fullRestore.getInputStreamForPackage(token, packageInfo))
// write and check data for second package
val data2 = getRandomByteArray(5 * 1024 * 1024)
fullBackup.getOutputStream(packageInfo2).writeAndClose(data2)
assertTrue(fullRestore.hasDataForPackage(token, packageInfo2))
assertReadEquals(data2, fullRestore.getInputStreamForPackage(token, packageInfo2))
// remove data of first package again and ensure that no more data is found
fullBackup.removeDataOfPackage(packageInfo)
assertFalse(fullRestore.hasDataForPackage(token, packageInfo))
// second package is still there
assertTrue(fullRestore.hasDataForPackage(token, packageInfo2))
// ensure that it gets deleted as well
fullBackup.removeDataOfPackage(packageInfo2)
assertFalse(fullRestore.hasDataForPackage(token, packageInfo2))
}
private fun initStorage(token: Long) = runBlocking {
every { mockedSettingsManager.getToken() } returns token
backupPlugin.initializeDevice()
}
private fun isNextcloud(): Boolean {
return backupPlugin.providerPackageName?.startsWith("com.nextcloud") ?: false
}
}

View file

@ -0,0 +1,218 @@
package com.stevesoltys.seedvault.plugins.saf
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.provider.DocumentsContract.EXTRA_LOADING
import androidx.documentfile.provider.DocumentFile
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.stevesoltys.seedvault.assertReadEquals
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomBase64
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.writeAndClose
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.KoinComponent
import org.koin.core.inject
import java.io.IOException
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
@Suppress("BlockingMethodInNonBlockingContext")
class DocumentsStorageTest : KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val metadataManager by inject<MetadataManager>()
private val settingsManager by inject<SettingsManager>()
private val storage = DocumentsStorage(context, settingsManager)
private val filename = getRandomBase64()
private lateinit var file: DocumentFile
@Before
fun setup() = runBlocking {
assertNotNull("Select a storage location in the app first!", storage.rootBackupDir)
file = storage.rootBackupDir?.createOrGetFile(context, filename)
?: error("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)
}
@Test
fun testFindFile() = runBlocking(Dispatchers.IO) {
val foundFile = storage.rootBackupDir!!.findFileBlocking(context, file.name!!)
assertNotNull(foundFile)
assertEquals(filename, foundFile!!.name)
assertEquals(storage.rootBackupDir!!.uri, foundFile.parentFile?.uri)
}
@Test
fun testCreateFile() {
// create test file
val dir = storage.rootBackupDir!!
val createdFile = dir.createFile("text", getRandomBase64())
assertNotNull(createdFile)
assertNotNull(createdFile!!.name)
// write some data into it
val data = getRandomByteArray()
context.contentResolver.openOutputStream(createdFile.uri)!!.writeAndClose(data)
// data should still be there
assertReadEquals(data, context.contentResolver.openInputStream(createdFile.uri))
// delete again
createdFile.delete()
assertFalse(createdFile.exists())
}
@Test
fun testCreateTwoFiles() = runBlocking {
val mimeType = "application/octet-stream"
val dir = storage.rootBackupDir!!
// create test file
val name1 = getRandomBase64(Random.nextInt(1, 10))
val file1 = requireNotNull(dir.createFile(mimeType, name1))
assertTrue(file1.exists())
assertEquals(name1, file1.name)
assertEquals(0L, file1.length())
assertReadEquals(getRandomByteArray(0), context.contentResolver.openInputStream(file1.uri))
// write some data into it
val data1 = getRandomByteArray(5 * 1024 * 1024)
context.contentResolver.openOutputStream(file1.uri)!!.writeAndClose(data1)
assertEquals(data1.size.toLong(), file1.length())
// data should still be there
assertReadEquals(data1, context.contentResolver.openInputStream(file1.uri))
// create test file
val name2 = getRandomBase64(Random.nextInt(1, 10))
val file2 = requireNotNull(dir.createFile(mimeType, name2))
assertTrue(file2.exists())
assertEquals(name2, file2.name)
// write some data into it
val data2 = getRandomByteArray(12 * 1024 * 1024)
context.contentResolver.openOutputStream(file2.uri)!!.writeAndClose(data2)
assertEquals(data2.size.toLong(), file2.length())
// data should still be there
assertReadEquals(data2, context.contentResolver.openInputStream(file2.uri))
// delete files again
file1.delete()
file2.delete()
assertFalse(file1.exists())
assertFalse(file2.exists())
}
@Test
fun testGetLoadedCursor() = runBlocking {
// empty cursor extras are like not loading, returns same cursor right away
val cursor1: Cursor = mockk()
every { cursor1.extras } returns Bundle()
assertEquals(cursor1, getLoadedCursor { cursor1 })
// explicitly not loading, returns same cursor right away
val cursor2: Cursor = mockk()
every { cursor2.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, false) }
assertEquals(cursor2, getLoadedCursor { cursor2 })
// loading cursor registers content observer, times out and closes cursor
val cursor3: Cursor = mockk()
every { cursor3.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) }
every { cursor3.registerContentObserver(any()) } just Runs
every { cursor3.close() } just Runs
coAssertThrows(TimeoutCancellationException::class.java) {
getLoadedCursor(1000) { cursor3 }
}
verify { cursor3.registerContentObserver(any()) }
verify { cursor3.close() } // ensure that cursor gets closed
// loading cursor registers content observer, but re-query fails
val cursor4: Cursor = mockk()
val observer4 = slot<ContentObserver>()
val query: () -> Cursor? = { if (observer4.isCaptured) null else cursor4 }
every { cursor4.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) }
every { cursor4.registerContentObserver(capture(observer4)) } answers {
observer4.captured.onChange(false, Uri.parse("foo://bar"))
}
every { cursor4.close() } just Runs
coAssertThrows(IOException::class.java) {
getLoadedCursor(10_000, query)
}
assertTrue(observer4.isCaptured)
verify { cursor4.close() } // ensure that cursor gets closed
// loading cursor registers content observer, re-queries and returns new result
val cursor5: Cursor = mockk()
val result5: Cursor = mockk()
val observer5 = slot<ContentObserver>()
val query5: () -> Cursor? = { if (observer5.isCaptured) result5 else cursor5 }
every { cursor5.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) }
every { cursor5.registerContentObserver(capture(observer5)) } answers {
observer5.captured.onChange(false, null)
}
every { cursor5.close() } just Runs
assertEquals(result5, getLoadedCursor(10_000, query5))
assertTrue(observer5.isCaptured)
verify { cursor5.close() } // ensure that initial cursor got closed
}
}

View file

@ -0,0 +1,23 @@
package com.stevesoltys.seedvault.transport.backup
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.KoinComponent
import org.koin.core.inject
@RunWith(AndroidJUnit4::class)
class PackageServiceTest : KoinComponent {
private val packageService: PackageService by inject()
@Test
fun testNotAllowedPackages() {
val packages = packageService.notAllowedPackages
assertTrue(packages.isNotEmpty())
Log.e("TEST", "Packages: $packages")
}
}

View file

@ -2,8 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="com.stevesoltys.seedvault" package="com.stevesoltys.seedvault"
android:versionCode="7" android:versionCode="29000001"
android:versionName="1.0.0"> android:versionName="10-1.0.0">
<!--
The version code is the targeted SDK_VERSION plus 6 digits for our own version code.
The version name is the targeted Android version followed by - and our own version name.
-->
<uses-permission <uses-permission
android:name="android.permission.BACKUP" android:name="android.permission.BACKUP"
@ -18,7 +22,7 @@
<uses-permission <uses-permission
android:name="android.permission.MANAGE_USB" android:name="android.permission.MANAGE_USB"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
g
<!-- This is needed to change system backup settings --> <!-- This is needed to change system backup settings -->
<uses-permission <uses-permission
android:name="android.permission.WRITE_SECURE_SETTINGS" android:name="android.permission.WRITE_SECURE_SETTINGS"

View file

@ -8,6 +8,7 @@ import android.os.Build
import android.os.ServiceManager.getService import android.os.ServiceManager.getService
import com.stevesoltys.seedvault.crypto.cryptoModule import com.stevesoltys.seedvault.crypto.cryptoModule
import com.stevesoltys.seedvault.header.headerModule import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.metadataModule import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
import com.stevesoltys.seedvault.restore.RestoreViewModel import com.stevesoltys.seedvault.restore.RestoreViewModel
@ -15,9 +16,11 @@ import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.SettingsViewModel import com.stevesoltys.seedvault.settings.SettingsViewModel
import com.stevesoltys.seedvault.transport.backup.backupModule import com.stevesoltys.seedvault.transport.backup.backupModule
import com.stevesoltys.seedvault.transport.restore.restoreModule import com.stevesoltys.seedvault.transport.restore.restoreModule
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel
import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
@ -36,9 +39,9 @@ class App : Application() {
single { Clock() } single { Clock() }
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) } factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
viewModel { SettingsViewModel(this@App, get(), get(), get()) } viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get()) }
viewModel { RecoveryCodeViewModel(this@App, get()) } viewModel { RecoveryCodeViewModel(this@App, get()) }
viewModel { BackupStorageViewModel(this@App, get(), get()) } viewModel { BackupStorageViewModel(this@App, get(), get(), get()) }
viewModel { RestoreStorageViewModel(this@App, get(), get()) } viewModel { RestoreStorageViewModel(this@App, get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) } viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
} }
@ -48,7 +51,8 @@ class App : Application() {
startKoin { startKoin {
androidLogger() androidLogger()
androidContext(this@App) androidContext(this@App)
modules(listOf( modules(
listOf(
cryptoModule, cryptoModule,
headerModule, headerModule,
metadataModule, metadataModule,
@ -56,7 +60,25 @@ class App : Application() {
backupModule, backupModule,
restoreModule, restoreModule,
appModule appModule
)) )
)
}
migrateTokenFromMetadataToSettingsManager()
}
private val settingsManager: SettingsManager by inject()
private val metadataManager: MetadataManager by inject()
/**
* The responsibility for the current token was moved to the [SettingsManager]
* in the end of 2020.
* This method migrates the token for existing installs and can be removed
* after sufficient time has passed.
*/
private fun migrateTokenFromMetadataToSettingsManager() {
val token = metadataManager.getBackupToken()
if (token != 0L && settingsManager.getToken() == null) {
settingsManager.setNewToken(token)
} }
} }

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.metadata package com.stevesoltys.seedvault.metadata
import android.content.pm.ApplicationInfo.FLAG_STOPPED
import android.os.Build import android.os.Build
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
@ -39,6 +40,10 @@ enum class PackageState {
* Package data could not get backed up, because the app reported no data to back up. * Package data could not get backed up, because the app reported no data to back up.
*/ */
NO_DATA, NO_DATA,
/**
* Package data could not get backed up, because the app has [FLAG_STOPPED].
*/
WAS_STOPPED,
/** /**
* Package data could not get backed up, because it was not allowed. * Package data could not get backed up, because it was not allowed.
* Most often, this is a manifest opt-out, but it could also be a disabled or system-user app. * Most often, this is a manifest opt-out, but it could also be a disabled or system-user app.

View file

@ -2,8 +2,6 @@ package com.stevesoltys.seedvault.metadata
import android.content.Context import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
@ -12,14 +10,17 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.distinctUntilChanged
import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.transport.backup.isSystemApp
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
private val TAG = MetadataManager::class.java.simpleName private val TAG = MetadataManager::class.java.simpleName
@VisibleForTesting @VisibleForTesting
internal const val METADATA_CACHE_FILE = "metadata.cache" internal const val METADATA_CACHE_FILE = "metadata.cache"
@ -28,7 +29,8 @@ class MetadataManager(
private val context: Context, private val context: Context,
private val clock: Clock, private val clock: Clock,
private val metadataWriter: MetadataWriter, private val metadataWriter: MetadataWriter,
private val metadataReader: MetadataReader) { private val metadataReader: MetadataReader
) {
private val uninitializedMetadata = BackupMetadata(token = 0L) private val uninitializedMetadata = BackupMetadata(token = 0L)
private var metadata: BackupMetadata = uninitializedMetadata private var metadata: BackupMetadata = uninitializedMetadata
@ -67,7 +69,11 @@ class MetadataManager(
*/ */
@Synchronized @Synchronized
@Throws(IOException::class) @Throws(IOException::class)
fun onApkBackedUp(packageInfo: PackageInfo, packageMetadata: PackageMetadata, metadataOutputStream: OutputStream) { fun onApkBackedUp(
packageInfo: PackageInfo,
packageMetadata: PackageMetadata,
metadataOutputStream: OutputStream
) {
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
metadata.packageMetadataMap[packageName]?.let { metadata.packageMetadataMap[packageName]?.let {
check(packageMetadata.version != null) { check(packageMetadata.version != null) {
@ -79,11 +85,15 @@ class MetadataManager(
} }
val oldPackageMetadata = metadata.packageMetadataMap[packageName] val oldPackageMetadata = metadata.packageMetadataMap[packageName]
?: PackageMetadata() ?: PackageMetadata()
// only allow state change if backup of this package is not allowed // only allow state change if backup of this package is not allowed,
val newState = if (packageMetadata.state == NOT_ALLOWED) // because we need to change from the default of UNKNOWN_ERROR here,
// but otherwise don't want to modify the state since set elsewhere.
val newState =
if (packageMetadata.state == NOT_ALLOWED || packageMetadata.state == WAS_STOPPED) {
packageMetadata.state packageMetadata.state
else } else {
oldPackageMetadata.state oldPackageMetadata.state
}
modifyMetadata(metadataOutputStream) { modifyMetadata(metadataOutputStream) {
metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy( metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy(
state = newState, state = newState,
@ -130,7 +140,11 @@ class MetadataManager(
*/ */
@Synchronized @Synchronized
@Throws(IOException::class) @Throws(IOException::class)
internal fun onPackageBackupError(packageInfo: PackageInfo, packageState: PackageState, metadataOutputStream: OutputStream) { internal fun onPackageBackupError(
packageInfo: PackageInfo,
packageState: PackageState,
metadataOutputStream: OutputStream
) {
check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." } check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
modifyMetadata(metadataOutputStream) { modifyMetadata(metadataOutputStream) {
@ -168,6 +182,7 @@ class MetadataManager(
* If the token is 0L, it is not yet initialized and must not be used for anything. * If the token is 0L, it is not yet initialized and must not be used for anything.
*/ */
@Synchronized @Synchronized
@Deprecated("Responsibility for current token moved to SettingsManager", ReplaceWith("settingsManager.getToken()"))
fun getBackupToken(): Long = metadata.token fun getBackupToken(): Long = metadata.token
/** /**
@ -187,9 +202,14 @@ class MetadataManager(
} }
@Synchronized @Synchronized
fun getPackagesNumNotBackedUp(): Int { fun getPackagesNumBackedUp(): Int {
// FIXME we are under-reporting packages here,
// because we have no way to also include upgraded system apps
return metadata.packageMetadataMap.filter { (_, packageMetadata) -> return metadata.packageMetadataMap.filter { (_, packageMetadata) ->
!packageMetadata.system && packageMetadata.state != APK_AND_DATA !packageMetadata.system && ( // ignore system apps
packageMetadata.state == APK_AND_DATA || // either full success
packageMetadata.state == NO_DATA // or apps that simply had no data
)
}.count() }.count()
} }
@ -219,13 +239,3 @@ class MetadataManager(
} }
} }
fun PackageInfo.isSystemApp(): Boolean {
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true
return applicationInfo.flags and FLAG_SYSTEM != 0
}
fun PackageInfo.isUpdatedSystemApp(): Boolean {
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false
return applicationInfo.flags and FLAG_UPDATED_SYSTEM_APP != 0
}

View file

@ -9,6 +9,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
import java.io.IOException import java.io.IOException
@ -64,11 +65,12 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
for (packageName in json.keys()) { for (packageName in json.keys()) {
if (packageName == JSON_METADATA) continue if (packageName == JSON_METADATA) continue
val p = json.getJSONObject(packageName) val p = json.getJSONObject(packageName)
val pState = when(p.optString(JSON_PACKAGE_STATE)) { val pState = when (p.optString(JSON_PACKAGE_STATE)) {
"" -> APK_AND_DATA "" -> APK_AND_DATA
QUOTA_EXCEEDED.name -> QUOTA_EXCEEDED QUOTA_EXCEEDED.name -> QUOTA_EXCEEDED
NO_DATA.name -> NO_DATA NO_DATA.name -> NO_DATA
NOT_ALLOWED.name -> NOT_ALLOWED NOT_ALLOWED.name -> NOT_ALLOWED
WAS_STOPPED.name -> WAS_STOPPED
else -> UNKNOWN_ERROR else -> UNKNOWN_ERROR
} }
val pSystem = p.optBoolean(JSON_PACKAGE_SYSTEM, false) val pSystem = p.optBoolean(JSON_PACKAGE_SYSTEM, false)

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import com.stevesoltys.seedvault.transport.backup.BackupPlugin import com.stevesoltys.seedvault.transport.backup.BackupPlugin
@ -10,52 +11,50 @@ import java.io.OutputStream
private const val MIME_TYPE_APK = "application/vnd.android.package-archive" private const val MIME_TYPE_APK = "application/vnd.android.package-archive"
@Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderBackupPlugin( internal class DocumentsProviderBackupPlugin(
private val context: Context,
private val storage: DocumentsStorage, private val storage: DocumentsStorage,
packageManager: PackageManager) : BackupPlugin { override val kvBackupPlugin: KVBackupPlugin,
override val fullBackupPlugin: FullBackupPlugin
) : BackupPlugin {
override val kvBackupPlugin: KVBackupPlugin by lazy { private val packageManager: PackageManager = context.packageManager
DocumentsProviderKVBackup(storage)
}
override val fullBackupPlugin: FullBackupPlugin by lazy {
DocumentsProviderFullBackup(storage)
}
@Throws(IOException::class) @Throws(IOException::class)
override fun initializeDevice(newToken: Long): Boolean { override suspend fun startNewRestoreSet(token: Long) {
// check if storage is already initialized
if (storage.isInitialized()) return false
// reset current storage // reset current storage
storage.reset(newToken) storage.reset(token)
// get or create root backup dir // get or create root backup dir
storage.rootBackupDir ?: throw IOException() storage.rootBackupDir ?: throw IOException()
// create backup folders
val kvDir = storage.currentKvBackupDir
val fullDir = storage.currentFullBackupDir
// wipe existing data
storage.getSetDir()?.findFile(FILE_BACKUP_METADATA)?.delete()
kvDir?.deleteContents()
fullDir?.deleteContents()
return true
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getMetadataOutputStream(): OutputStream { override suspend fun initializeDevice() {
// wipe existing data
storage.getSetDir()?.deleteContents(context)
// reset storage without new token, so folders get recreated
// otherwise stale DocumentFiles will hang around
storage.reset(null)
// create backup folders
storage.currentKvBackupDir ?: throw IOException()
storage.currentFullBackupDir ?: throw IOException()
}
@Throws(IOException::class)
override suspend fun getMetadataOutputStream(): OutputStream {
val setDir = storage.getSetDir() ?: throw IOException() val setDir = storage.getSetDir() ?: throw IOException()
val metadataFile = setDir.createOrGetFile(FILE_BACKUP_METADATA) val metadataFile = setDir.createOrGetFile(context, FILE_BACKUP_METADATA)
return storage.getOutputStream(metadataFile) return storage.getOutputStream(metadataFile)
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getApkOutputStream(packageInfo: PackageInfo): OutputStream { override suspend fun getApkOutputStream(packageInfo: PackageInfo): OutputStream {
val setDir = storage.getSetDir() ?: throw IOException() val setDir = storage.getSetDir() ?: throw IOException()
val file = setDir.createOrGetFile("${packageInfo.packageName}.apk", MIME_TYPE_APK) val file = setDir.createOrGetFile(context, "${packageInfo.packageName}.apk", MIME_TYPE_APK)
return storage.getOutputStream(file) return storage.getOutputStream(file)
} }

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_FULL_BACKUP import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_FULL_BACKUP
@ -9,23 +10,27 @@ import java.io.OutputStream
private val TAG = DocumentsProviderFullBackup::class.java.simpleName private val TAG = DocumentsProviderFullBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderFullBackup( internal class DocumentsProviderFullBackup(
private val storage: DocumentsStorage) : FullBackupPlugin { private val context: Context,
private val storage: DocumentsStorage
) : FullBackupPlugin {
override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP
@Throws(IOException::class) @Throws(IOException::class)
override fun getOutputStream(targetPackage: PackageInfo): OutputStream { override suspend fun getOutputStream(targetPackage: PackageInfo): OutputStream {
val file = storage.currentFullBackupDir?.createOrGetFile(targetPackage.packageName) val file = storage.currentFullBackupDir?.createOrGetFile(context, targetPackage.packageName)
?: throw IOException() ?: throw IOException()
return storage.getOutputStream(file) return storage.getOutputStream(file)
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun removeDataOfPackage(packageInfo: PackageInfo) { override suspend fun removeDataOfPackage(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
Log.i(TAG, "Deleting $packageName...") Log.i(TAG, "Deleting $packageName...")
val file = storage.currentFullBackupDir?.findFile(packageName) ?: return val file = storage.currentFullBackupDir?.findFileBlocking(context, packageName)
?: return
if (!file.delete()) throw IOException("Failed to delete $packageName") if (!file.delete()) throw IOException("Failed to delete $packageName")
} }

View file

@ -1,23 +1,31 @@
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
@Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderFullRestorePlugin( internal class DocumentsProviderFullRestorePlugin(
private val documentsStorage: DocumentsStorage) : FullRestorePlugin { private val context: Context,
private val documentsStorage: DocumentsStorage
) : FullRestorePlugin {
@Throws(IOException::class) @Throws(IOException::class)
override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { override suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException() val backupDir = documentsStorage.getFullBackupDir(token) ?: return false
return backupDir.findFile(packageInfo.packageName) != null return backupDir.findFileBlocking(context, packageInfo.packageName) != null
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream { override suspend fun getInputStreamForPackage(
token: Long,
packageInfo: PackageInfo
): InputStream {
val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException() val backupDir = documentsStorage.getFullBackupDir(token) ?: throw IOException()
val packageFile = backupDir.findFile(packageInfo.packageName) ?: throw IOException() val packageFile =
backupDir.findFileBlocking(context, packageInfo.packageName) ?: throw IOException()
return documentsStorage.getInputStream(packageFile) return documentsStorage.getInputStream(packageFile)
} }

View file

@ -1,53 +1,105 @@
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.util.Log
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_KEY_VALUE_BACKUP import com.stevesoltys.seedvault.transport.backup.DEFAULT_QUOTA_KEY_VALUE_BACKUP
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
internal class DocumentsProviderKVBackup(private val storage: DocumentsStorage) : KVBackupPlugin { const val MAX_KEY_LENGTH = 255
const val MAX_KEY_LENGTH_NEXTCLOUD = 225
@Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderKVBackup(
private val context: Context,
private val storage: DocumentsStorage
) : KVBackupPlugin {
private var packageFile: DocumentFile? = null private var packageFile: DocumentFile? = null
private var packageChildren: List<DocumentFile>? = null
override fun getQuota(): Long = DEFAULT_QUOTA_KEY_VALUE_BACKUP override fun getQuota(): Long = DEFAULT_QUOTA_KEY_VALUE_BACKUP
@Throws(IOException::class) @Throws(IOException::class)
override fun hasDataForPackage(packageInfo: PackageInfo): Boolean { override suspend fun hasDataForPackage(packageInfo: PackageInfo): Boolean {
val packageFile = storage.currentKvBackupDir?.findFile(packageInfo.packageName) // get the folder for the package (or create it) and all files in it
?: return false val dir =
return packageFile.listFiles().isNotEmpty() storage.getOrCreateKVBackupDir().createOrGetDirectory(context, packageInfo.packageName)
val children = dir.listFilesBlocking(context)
// cache package file for subsequent operations
packageFile = dir
// also cache children as doing this for every record is super slow
packageChildren = children
return children.isNotEmpty()
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun ensureRecordStorageForPackage(packageInfo: PackageInfo) { override suspend fun getOutputStreamForRecord(
// remember package file for subsequent operations packageInfo: PackageInfo,
packageFile = storage.getOrCreateKVBackupDir().createOrGetDirectory(packageInfo.packageName) key: String
): OutputStream {
// check maximum key lengths
check(key.length <= MAX_KEY_LENGTH) {
"Key $key for ${packageInfo.packageName} is too long: ${key.length} chars."
} }
if (key.length > MAX_KEY_LENGTH_NEXTCLOUD) {
@Throws(IOException::class) Log.e(
override fun removeDataOfPackage(packageInfo: PackageInfo) { DocumentsProviderKVBackup::class.java.simpleName,
// we cannot use the cached this.packageFile here, "Key $key for ${packageInfo.packageName} is too long: ${key.length} chars."
// because this can be called before [ensureRecordStorageForPackage] )
val packageFile = storage.currentKvBackupDir?.findFile(packageInfo.packageName) ?: return
packageFile.delete()
} }
// get dir and children from cache
@Throws(IOException::class) val packageFile = this.packageFile
override fun deleteRecord(packageInfo: PackageInfo, key: String) { ?: throw AssertionError("No cached packageFile for ${packageInfo.packageName}")
val packageFile = this.packageFile ?: throw AssertionError()
packageFile.assertRightFile(packageInfo) packageFile.assertRightFile(packageInfo)
val keyFile = packageFile.findFile(key) ?: return val children = packageChildren
keyFile.delete() ?: throw AssertionError("No cached children for ${packageInfo.packageName}")
}
@Throws(IOException::class) // get file for key from cache,
override fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream { val keyFile = children.find { it.name == key } // try cache first
val packageFile = this.packageFile ?: throw AssertionError() ?: packageFile.createFile(MIME_TYPE, key) // assume it doesn't exist, create it
packageFile.assertRightFile(packageInfo) ?: packageFile.createOrGetFile(context, key) // cache was stale, so try to find it
val keyFile = packageFile.createOrGetFile(key) check(keyFile.name == key) { "Key file named ${keyFile.name}, but should be $key" }
return storage.getOutputStream(keyFile) return storage.getOutputStream(keyFile)
} }
@Throws(IOException::class)
override suspend fun deleteRecord(packageInfo: PackageInfo, key: String) {
val packageFile = this.packageFile
?: throw AssertionError("No cached packageFile for ${packageInfo.packageName}")
packageFile.assertRightFile(packageInfo)
val children = packageChildren
?: throw AssertionError("No cached children for ${packageInfo.packageName}")
// try to find file for given key and delete it if found
val keyFile = children.find { it.name == key } // try to find in cache
?: packageFile.findFileBlocking(context, key) // fall-back to provider
?: return // not found, nothing left to do
keyFile.delete()
// we don't update the children cache as deleted records
// are not expected to get re-added in the same backup pass
}
@Throws(IOException::class)
override suspend fun removeDataOfPackage(packageInfo: PackageInfo) {
val packageFile = this.packageFile
?: throw AssertionError("No cached packageFile for ${packageInfo.packageName}")
packageFile.assertRightFile(packageInfo)
// We are not using the cached children here in case they are stale.
// This operation isn't frequent, so we don't need to heavily optimize it.
packageFile.deleteContents(context)
// clear children cache
packageChildren = ArrayList()
}
override fun packageFinished(packageInfo: PackageInfo) {
packageFile = null
packageChildren = null
}
} }

View file

@ -1,39 +1,60 @@
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
internal class DocumentsProviderKVRestorePlugin(private val storage: DocumentsStorage) : KVRestorePlugin { @Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderKVRestorePlugin(
private val context: Context,
private val storage: DocumentsStorage
) : KVRestorePlugin {
private var packageDir: DocumentFile? = null private var packageDir: DocumentFile? = null
private var packageChildren: List<DocumentFile>? = null
override fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { override suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
return try { return try {
val backupDir = storage.getKVBackupDir(token) ?: return false val backupDir = storage.getKVBackupDir(token) ?: return false
val dir = backupDir.findFileBlocking(context, packageInfo.packageName) ?: return false
val children = dir.listFilesBlocking(context)
// remember package file for subsequent operations // remember package file for subsequent operations
packageDir = backupDir.findFile(packageInfo.packageName) packageDir = dir
packageDir != null // remember package children for subsequent operations
packageChildren = children
// we have data if we have a non-empty list of children
children.isNotEmpty()
} catch (e: IOException) { } catch (e: IOException) {
false false
} }
} }
override fun listRecords(token: Long, packageInfo: PackageInfo): List<String> { @Throws(IOException::class)
val packageDir = this.packageDir ?: throw AssertionError() override suspend fun listRecords(token: Long, packageInfo: PackageInfo): List<String> {
val packageDir = this.packageDir
?: throw AssertionError("No cached packageDir for ${packageInfo.packageName}")
packageDir.assertRightFile(packageInfo) packageDir.assertRightFile(packageInfo)
return packageDir.listFiles() return packageChildren
.filter { file -> file.name != null } ?.filter { file -> file.name != null }
.map { file -> file.name!! } ?.map { file -> file.name!! }
?: throw AssertionError("No cached children for ${packageInfo.packageName}")
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream { override suspend fun getInputStreamForRecord(
val packageDir = this.packageDir ?: throw AssertionError() token: Long,
packageInfo: PackageInfo,
key: String
): InputStream {
val packageDir = this.packageDir
?: throw AssertionError("No cached packageDir for ${packageInfo.packageName}")
packageDir.assertRightFile(packageInfo) packageDir.assertRightFile(packageInfo)
val keyFile = packageDir.findFile(key) ?: throw IOException() val keyFile = packageChildren?.find { it.name == key }
?: packageDir.findFileBlocking(context, key)
?: throw IOException()
return storage.getInputStream(keyFile) return storage.getInputStream(keyFile)
} }

View file

@ -1,12 +1,22 @@
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import com.stevesoltys.seedvault.transport.backup.BackupPlugin import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.RestorePlugin import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
val documentsProviderModule = module { val documentsProviderModule = module {
single { DocumentsStorage(androidContext(), get(), get()) } single { DocumentsStorage(androidContext(), get()) }
single<BackupPlugin> { DocumentsProviderBackupPlugin(get(), androidContext().packageManager) }
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get()) } single<KVBackupPlugin> { DocumentsProviderKVBackup(androidContext(), get()) }
single<FullBackupPlugin> { DocumentsProviderFullBackup(androidContext(), get()) }
single<BackupPlugin> { DocumentsProviderBackupPlugin(androidContext(), get(), get(), get()) }
single<KVRestorePlugin> { DocumentsProviderKVRestorePlugin(androidContext(), get()) }
single<FullRestorePlugin> { DocumentsProviderFullRestorePlugin(androidContext(), get()) }
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get(), get(), get()) }
} }

View file

@ -15,27 +15,24 @@ import java.io.InputStream
private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
@WorkerThread
@Suppress("BlockingMethodInNonBlockingContext") // all methods do I/O
internal class DocumentsProviderRestorePlugin( internal class DocumentsProviderRestorePlugin(
private val context: Context, private val context: Context,
private val storage: DocumentsStorage) : RestorePlugin { private val storage: DocumentsStorage,
override val kvRestorePlugin: KVRestorePlugin,
override val fullRestorePlugin: FullRestorePlugin
) : RestorePlugin {
override val kvRestorePlugin: KVRestorePlugin by lazy { @Throws(IOException::class)
DocumentsProviderKVRestorePlugin(storage) override suspend fun hasBackup(uri: Uri): Boolean {
}
override val fullRestorePlugin: FullRestorePlugin by lazy {
DocumentsProviderFullRestorePlugin(storage)
}
@WorkerThread
override fun hasBackup(uri: Uri): Boolean {
val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError() val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError()
val rootDir = parent.findFileBlocking(context, DIRECTORY_ROOT) ?: return false val rootDir = parent.findFileBlocking(context, DIRECTORY_ROOT) ?: return false
val backupSets = getBackups(context, rootDir) val backupSets = getBackups(context, rootDir)
return backupSets.isNotEmpty() return backupSets.isNotEmpty()
} }
override fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? { override suspend fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? {
val rootDir = storage.rootBackupDir ?: return null val rootDir = storage.rootBackupDir ?: return null
val backupSets = getBackups(context, rootDir) val backupSets = getBackups(context, rootDir)
val iterator = backupSets.iterator() val iterator = backupSets.iterator()
@ -52,8 +49,7 @@ internal class DocumentsProviderRestorePlugin(
} }
} }
@WorkerThread private suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
val backupSets = ArrayList<BackupSet>() val backupSets = ArrayList<BackupSet>()
val files = try { val files = try {
// block until the DocumentsProvider has results // block until the DocumentsProvider has results
@ -63,20 +59,16 @@ internal class DocumentsProviderRestorePlugin(
return backupSets return backupSets
} }
for (set in files) { for (set in files) {
if (!set.isDirectory || set.name == null) { // get current token from set or continue to next file/set
if (set.name != FILE_NO_MEDIA) { val token = set.getTokenOrNull() ?: continue
Log.w(TAG, "Found invalid backup set folder: ${set.name}")
}
continue
}
val token = try {
set.name!!.toLong()
} catch (e: NumberFormatException) {
Log.w(TAG, "Found invalid backup set folder: ${set.name}")
continue
}
// block until children of set are available // block until children of set are available
val metadata = set.findFileBlocking(context, FILE_BACKUP_METADATA) val metadata = try {
set.findFileBlocking(context, FILE_BACKUP_METADATA)
} catch (e: IOException) {
Log.e(TAG, "Error reading metadata file in backup set folder: ${set.name}", e)
null
}
if (metadata == null) { if (metadata == null) {
Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}") Log.w(TAG, "Missing metadata file in backup set folder: ${set.name}")
} else { } else {
@ -86,10 +78,26 @@ internal class DocumentsProviderRestorePlugin(
return backupSets return backupSets
} }
private fun DocumentFile.getTokenOrNull(): Long? {
if (!isDirectory || name == null) {
if (name != FILE_NO_MEDIA) {
Log.w(TAG, "Found invalid backup set folder: $name")
}
return null
}
return try {
name!!.toLong()
} catch (e: NumberFormatException) {
Log.w(TAG, "Found invalid backup set folder: $name")
null
}
}
@Throws(IOException::class) @Throws(IOException::class)
override fun getApkInputStream(token: Long, packageName: String): InputStream { override suspend fun getApkInputStream(token: Long, packageName: String): InputStream {
val setDir = storage.getSetDir(token) ?: throw IOException() val setDir = storage.getSetDir(token) ?: throw IOException()
val file = setDir.findFile("$packageName.apk") ?: throw FileNotFoundException() val file =
setDir.findFileBlocking(context, "$packageName.apk") ?: throw FileNotFoundException()
return storage.getInputStream(file) return storage.getInputStream(file)
} }

View file

@ -1,42 +1,47 @@
@file:Suppress("BlockingMethodInNonBlockingContext")
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.database.ContentObserver import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.FileUtils.closeQuietly
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
import android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE
import android.provider.DocumentsContract.Document.MIME_TYPE_DIR
import android.provider.DocumentsContract.EXTRA_LOADING import android.provider.DocumentsContract.EXTRA_LOADING
import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree
import android.provider.DocumentsContract.buildDocumentUriUsingTree import android.provider.DocumentsContract.buildDocumentUriUsingTree
import android.provider.DocumentsContract.buildTreeDocumentUri
import android.provider.DocumentsContract.getDocumentId import android.provider.DocumentsContract.getDocumentId
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.settings.Storage
import libcore.io.IoUtils.closeQuietly import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.concurrent.TimeUnit.MINUTES import kotlin.coroutines.resume
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup" const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
const val DIRECTORY_FULL_BACKUP = "full" const val DIRECTORY_FULL_BACKUP = "full"
const val DIRECTORY_KEY_VALUE_BACKUP = "kv" const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
const val FILE_BACKUP_METADATA = ".backup.metadata" const val FILE_BACKUP_METADATA = ".backup.metadata"
const val FILE_NO_MEDIA = ".nomedia" const val FILE_NO_MEDIA = ".nomedia"
private const val MIME_TYPE = "application/octet-stream" const val MIME_TYPE = "application/octet-stream"
private val TAG = DocumentsStorage::class.java.simpleName private val TAG = DocumentsStorage::class.java.simpleName
internal class DocumentsStorage( internal class DocumentsStorage(
private val context: Context, private val context: Context,
private val metadataManager: MetadataManager, private val settingsManager: SettingsManager
private val settingsManager: SettingsManager) { ) {
private val contentResolver = context.contentResolver
internal var storage: Storage? = null internal var storage: Storage? = null
get() { get() {
@ -45,76 +50,74 @@ internal class DocumentsStorage(
} }
internal var rootBackupDir: DocumentFile? = null internal var rootBackupDir: DocumentFile? = null
get() { get() = runBlocking {
if (field == null) { if (field == null) {
val parent = storage?.getDocumentFile(context) ?: return null val parent = storage?.getDocumentFile(context)
?: return@runBlocking null
field = try { field = try {
val rootDir = parent.createOrGetDirectory(DIRECTORY_ROOT) parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply {
// create .nomedia file to prevent Android's MediaScanner from trying to index the backup // create .nomedia file to prevent Android's MediaScanner
rootDir.createOrGetFile(FILE_NO_MEDIA) // from trying to index the backup
rootDir createOrGetFile(context, FILE_NO_MEDIA)
}
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error creating root backup dir.", e) Log.e(TAG, "Error creating root backup dir.", e)
null null
} }
} }
return field field
} }
private var currentToken: Long = 0L private var currentToken: Long? = null
get() { get() {
if (field == 0L) field = metadataManager.getBackupToken() if (field == null) field = settingsManager.getToken()
return field return field
} }
private var currentSetDir: DocumentFile? = null private var currentSetDir: DocumentFile? = null
get() { get() = runBlocking {
if (field == null) { if (field == null) {
if (currentToken == 0L) return null if (currentToken == 0L) return@runBlocking null
field = try { field = try {
rootBackupDir?.createOrGetDirectory(currentToken.toString()) rootBackupDir?.createOrGetDirectory(context, currentToken.toString())
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e) Log.e(TAG, "Error creating current restore set dir.", e)
null null
} }
} }
return field field
} }
var currentFullBackupDir: DocumentFile? = null var currentFullBackupDir: DocumentFile? = null
get() { get() = runBlocking {
if (field == null) { if (field == null) {
field = try { field = try {
currentSetDir?.createOrGetDirectory(DIRECTORY_FULL_BACKUP) currentSetDir?.createOrGetDirectory(context, DIRECTORY_FULL_BACKUP)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error creating full backup dir.", e) Log.e(TAG, "Error creating full backup dir.", e)
null null
} }
} }
return field field
} }
var currentKvBackupDir: DocumentFile? = null var currentKvBackupDir: DocumentFile? = null
get() { get() = runBlocking {
if (field == null) { if (field == null) {
field = try { field = try {
currentSetDir?.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP) currentSetDir?.createOrGetDirectory(context, DIRECTORY_KEY_VALUE_BACKUP)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error creating K/V backup dir.", e) Log.e(TAG, "Error creating K/V backup dir.", e)
null null
} }
} }
return field field
} }
fun isInitialized(): Boolean { /**
if (settingsManager.getAndResetIsStorageChanging()) return false // storage location has changed * Resets this storage abstraction, forcing it to re-fetch cached values on next access.
val kvEmpty = currentKvBackupDir?.listFiles()?.isEmpty() ?: false */
val fullEmpty = currentFullBackupDir?.listFiles()?.isEmpty() ?: false fun reset(newToken: Long?) {
return kvEmpty && fullEmpty
}
fun reset(newToken: Long) {
storage = null storage = null
currentToken = newToken currentToken = newToken
rootBackupDir = null rootBackupDir = null
@ -125,57 +128,80 @@ internal class DocumentsStorage(
fun getAuthority(): String? = storage?.uri?.authority fun getAuthority(): String? = storage?.uri?.authority
fun getSetDir(token: Long = currentToken): DocumentFile? { @Throws(IOException::class)
suspend fun getSetDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
if (token == currentToken) return currentSetDir if (token == currentToken) return currentSetDir
return rootBackupDir?.findFile(token.toString()) return rootBackupDir?.findFileBlocking(context, token.toString())
}
fun getKVBackupDir(token: Long = currentToken): DocumentFile? {
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
return getSetDir(token)?.findFile(DIRECTORY_KEY_VALUE_BACKUP)
} }
@Throws(IOException::class) @Throws(IOException::class)
fun getOrCreateKVBackupDir(token: Long = currentToken): DocumentFile { suspend fun getKVBackupDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
if (token == currentToken) return currentKvBackupDir ?: throw IOException() if (token == currentToken) return currentKvBackupDir ?: throw IOException()
val setDir = getSetDir(token) ?: throw IOException() return getSetDir(token)?.findFileBlocking(context, DIRECTORY_KEY_VALUE_BACKUP)
return setDir.createOrGetDirectory(DIRECTORY_KEY_VALUE_BACKUP)
} }
fun getFullBackupDir(token: Long = currentToken): DocumentFile? { @Throws(IOException::class)
suspend fun getOrCreateKVBackupDir(
token: Long = currentToken ?: error("no token")
): DocumentFile {
if (token == currentToken) return currentKvBackupDir ?: throw IOException()
val setDir = getSetDir(token) ?: throw IOException()
return setDir.createOrGetDirectory(context, DIRECTORY_KEY_VALUE_BACKUP)
}
@Throws(IOException::class)
suspend fun getFullBackupDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
if (token == currentToken) return currentFullBackupDir ?: throw IOException() if (token == currentToken) return currentFullBackupDir ?: throw IOException()
return getSetDir(token)?.findFile(DIRECTORY_FULL_BACKUP) return getSetDir(token)?.findFileBlocking(context, DIRECTORY_FULL_BACKUP)
} }
@Throws(IOException::class) @Throws(IOException::class)
fun getInputStream(file: DocumentFile): InputStream { fun getInputStream(file: DocumentFile): InputStream {
return context.contentResolver.openInputStream(file.uri) ?: throw IOException() return contentResolver.openInputStream(file.uri) ?: throw IOException()
} }
@Throws(IOException::class) @Throws(IOException::class)
fun getOutputStream(file: DocumentFile): OutputStream { fun getOutputStream(file: DocumentFile): OutputStream {
return context.contentResolver.openOutputStream(file.uri, "wt") ?: throw IOException() return contentResolver.openOutputStream(file.uri, "wt") ?: throw IOException()
} }
} }
/**
* Checks if a file exists and if not, creates it.
*
* If we were trying to create it right away, some providers create "filename (1)".
*/
@Throws(IOException::class) @Throws(IOException::class)
fun DocumentFile.createOrGetFile(name: String, mimeType: String = MIME_TYPE): DocumentFile { internal suspend fun DocumentFile.createOrGetFile(
return findFile(name) ?: createFile(mimeType, name) ?: throw IOException() context: Context,
name: String,
mimeType: String = MIME_TYPE
): DocumentFile {
return findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply {
check(this.name == name) { "File named ${this.name}, but should be $name" }
} ?: throw IOException()
}
/**
* Checks if a directory already exists and if not, creates it.
*/
@Throws(IOException::class)
suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): DocumentFile {
return findFileBlocking(context, name) ?: createDirectory(name)?.apply {
check(this.name == name) { "Directory named ${this.name}, but should be $name" }
} ?: throw IOException()
} }
@Throws(IOException::class) @Throws(IOException::class)
fun DocumentFile.createOrGetDirectory(name: String): DocumentFile { suspend fun DocumentFile.deleteContents(context: Context) {
return findFile(name) ?: createDirectory(name) ?: throw IOException() for (file in listFilesBlocking(context)) file.delete()
}
@Throws(IOException::class)
fun DocumentFile.deleteContents() {
for (file in listFiles()) file.delete()
} }
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) { fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
if (name != packageInfo.packageName) throw AssertionError() if (name != packageInfo.packageName) {
throw AssertionError("Expected ${packageInfo.packageName}, but got $name")
}
} }
/** /**
@ -183,56 +209,56 @@ fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
* This prevents getting an empty list even though there are children to be listed. * This prevents getting an empty list even though there are children to be listed.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> { suspend fun DocumentFile.listFilesBlocking(context: Context): List<DocumentFile> {
val resolver = context.contentResolver val resolver = context.contentResolver
val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri)) val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri))
val projection = arrayOf(COLUMN_DOCUMENT_ID, COLUMN_MIME_TYPE) val projection = arrayOf(COLUMN_DOCUMENT_ID)
val result = ArrayList<DocumentFile>() val result = ArrayList<DocumentFile>()
@SuppressLint("Recycle") // gets closed in with(), only earlier exit when null try {
var cursor = resolver.query(childrenUri, projection, null, null, null) getLoadedCursor {
?: throw IOException() resolver.query(childrenUri, projection, null, null, null)
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)
if (loading) {
Log.d(TAG, "Wait for children to get loaded...")
var loaded = false
cursor.registerContentObserver(object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
Log.d(TAG, "Children loaded. Continue...")
loaded = true
} }
}) } catch (e: TimeoutCancellationException) {
val timeout = MINUTES.toMillis(2) throw IOException(e)
var time = 0 }.use { cursor ->
while (!loaded && time < timeout) { while (cursor.moveToNext()) {
Thread.sleep(50) val documentId = cursor.getString(0)
time += 50
}
if (time >= timeout) Log.w(TAG, "Timed out while waiting for children to load")
closeQuietly(cursor)
// do a new query after content was loaded
@SuppressLint("Recycle") // gets closed after with block
cursor = resolver.query(childrenUri, projection, null, null, null)
?: throw IOException()
}
with(cursor) {
while (moveToNext()) {
val documentId = getString(0)
val isDirectory = getString(1) == MIME_TYPE_DIR
val file = if (isDirectory) {
val treeUri = buildTreeDocumentUri(uri.authority, documentId)
DocumentFile.fromTreeUri(context, treeUri)!!
} else {
val documentUri = buildDocumentUriUsingTree(uri, documentId) val documentUri = buildDocumentUriUsingTree(uri, documentId)
DocumentFile.fromSingleUri(context, documentUri)!! result.add(getTreeDocumentFile(this, context, documentUri))
}
result.add(file)
} }
} }
return result return result
} }
fun DocumentFile.findFileBlocking(context: Context, displayName: String): DocumentFile? { /**
* An extremely dirty reflection hack to instantiate a TreeDocumentFile with a parent.
*
* All other public ways to get a TreeDocumentFile only work from [Uri]s
* (e.g. [DocumentFile.fromTreeUri]) and always set parent to null.
*
* We have a test for this method to ensure CI will alert us when this reflection breaks.
* Also, [DocumentFile] is part of AndroidX, so we control the dependency and notice when it fails.
*/
@VisibleForTesting
internal fun getTreeDocumentFile(parent: DocumentFile, context: Context, uri: Uri): DocumentFile {
@SuppressWarnings("MagicNumber")
val constructor = parent.javaClass.declaredConstructors.find {
it.name == "androidx.documentfile.provider.TreeDocumentFile" && it.parameterCount == 3
}
check(constructor != null) { "Could not find constructor for TreeDocumentFile" }
constructor.isAccessible = true
return constructor.newInstance(parent, context, uri) as DocumentFile
}
/**
* Same as [DocumentFile.findFile] only that it re-queries when the first result was stale.
*
* Most documents providers including Nextcloud are listing the full directory content
* when querying for a specific file in a directory,
* so there is no point in trying to optimize the query by not listing all children.
*/
suspend fun DocumentFile.findFileBlocking(context: Context, displayName: String): DocumentFile? {
val files = try { val files = try {
listFilesBlocking(context) listFilesBlocking(context)
} catch (e: IOException) { } catch (e: IOException) {
@ -244,3 +270,46 @@ fun DocumentFile.findFileBlocking(context: Context, displayName: String): Docume
} }
return null return null
} }
/**
* Returns a cursor for the given query while ensuring that the cursor was loaded.
*
* When the SAF backend is a cloud storage provider (e.g. Nextcloud),
* it can happen that the query returns an outdated (e.g. empty) cursor
* which will only be updated in response to this query.
*
* See: https://commonsware.com/blog/2019/12/14/scoped-storage-stories-listfiles-woe.html
*
* This method uses a [suspendCancellableCoroutine] to wait for the result of a [ContentObserver]
* registered on the cursor in case the cursor is still loading ([EXTRA_LOADING]).
* If the cursor is not loading, it will be returned right away.
*
* @param timeout an optional time-out in milliseconds
* @throws TimeoutCancellationException if there was no result before the time-out
* @throws IOException if the query returns null
*/
@VisibleForTesting
@Throws(IOException::class, TimeoutCancellationException::class)
internal suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?) =
withTimeout(timeout) {
suspendCancellableCoroutine<Cursor> { cont ->
val cursor = query() ?: throw IOException()
cont.invokeOnCancellation { closeQuietly(cursor) }
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)
if (loading) {
Log.d(TAG, "Wait for children to get loaded...")
cursor.registerContentObserver(object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
Log.d(TAG, "Children loaded. Continue...")
closeQuietly(cursor)
val newCursor = query()
if (newCursor == null) cont.cancel(IOException("query returned no results"))
else cont.resume(newCursor)
}
})
} else {
// not loading, return cursor right away
cont.resume(cursor)
}
}
}

View file

@ -4,16 +4,19 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED
import com.stevesoltys.seedvault.transport.restore.InstallResult import com.stevesoltys.seedvault.transport.restore.InstallResult
import com.stevesoltys.seedvault.transport.restore.getInProgress import com.stevesoltys.seedvault.transport.restore.getInProgress
import kotlinx.android.synthetic.main.fragment_restore_progress.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class InstallProgressFragment : Fragment() { class InstallProgressFragment : Fragment() {
@ -23,9 +26,23 @@ class InstallProgressFragment : Fragment() {
private val layoutManager = LinearLayoutManager(context) private val layoutManager = LinearLayoutManager(context)
private val adapter = InstallProgressAdapter() private val adapter = InstallProgressAdapter()
private lateinit var progressBar: ProgressBar
private lateinit var titleView: TextView
private lateinit var backupNameView: TextView
private lateinit var appList: RecyclerView
private lateinit var button: Button
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? { savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_restore_progress, container, false) val v: View = inflater.inflate(R.layout.fragment_restore_progress, container, false)
progressBar = v.findViewById(R.id.progressBar)
titleView = v.findViewById(R.id.titleView)
backupNameView = v.findViewById(R.id.backupNameView)
appList = v.findViewById(R.id.appList)
button = v.findViewById(R.id.button)
return v
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View file

@ -5,7 +5,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import androidx.core.net.toUri import androidx.core.net.toUri
import com.stevesoltys.seedvault.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import org.koin.core.context.GlobalContext.get import org.koin.core.context.GlobalContext.get
internal const val ACTION_RESTORE_ERROR_UNINSTALL = "com.stevesoltys.seedvault.action.UNINSTALL" internal const val ACTION_RESTORE_ERROR_UNINSTALL = "com.stevesoltys.seedvault.action.UNINSTALL"

View file

@ -10,7 +10,7 @@ import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreProgressAdapter.PackageViewHolder import com.stevesoltys.seedvault.restore.RestoreProgressAdapter.PackageViewHolder
import com.stevesoltys.seedvault.ui.AppViewHolder import com.stevesoltys.seedvault.ui.AppViewHolder
import java.util.* import java.util.LinkedList
internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() { internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
@ -50,7 +50,7 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
} }
} }
inner class PackageViewHolder(v: View) : AppViewHolder(v) { class PackageViewHolder(v: View) : AppViewHolder(v) {
fun bind(item: AppRestoreResult) { fun bind(item: AppRestoreResult) {
appName.text = item.name appName.text = item.name
if (item.packageName == MAGIC_PACKAGE_MANAGER) { if (item.packageName == MAGIC_PACKAGE_MANAGER) {
@ -71,8 +71,10 @@ internal class RestoreProgressAdapter : Adapter<PackageViewHolder>() {
enum class AppRestoreStatus { enum class AppRestoreStatus {
IN_PROGRESS, IN_PROGRESS,
SUCCEEDED, SUCCEEDED,
NOT_YET_BACKED_UP,
FAILED, FAILED,
FAILED_NO_DATA, FAILED_NO_DATA,
FAILED_WAS_STOPPED,
FAILED_NOT_ALLOWED, FAILED_NOT_ALLOWED,
FAILED_QUOTA_EXCEEDED, FAILED_QUOTA_EXCEEDED,
FAILED_NOT_INSTALLED, FAILED_NOT_INSTALLED,

View file

@ -6,14 +6,17 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.ContextCompat.getColor import androidx.core.content.ContextCompat.getColor
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL import androidx.recyclerview.widget.LinearLayoutManager.VERTICAL
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_restore_progress.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RestoreProgressFragment : Fragment() { class RestoreProgressFragment : Fragment() {
@ -23,9 +26,23 @@ class RestoreProgressFragment : Fragment() {
private val layoutManager = LinearLayoutManager(context) private val layoutManager = LinearLayoutManager(context)
private val adapter = RestoreProgressAdapter() private val adapter = RestoreProgressAdapter()
private lateinit var progressBar: ProgressBar
private lateinit var titleView: TextView
private lateinit var backupNameView: TextView
private lateinit var appList: RecyclerView
private lateinit var button: Button
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? { savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_restore_progress, container, false) val v: View = inflater.inflate(R.layout.fragment_restore_progress, container, false)
progressBar = v.findViewById(R.id.progressBar)
titleView = v.findViewById(R.id.titleView)
backupNameView = v.findViewById(R.id.backupNameView)
appList = v.findViewById(R.id.appList)
button = v.findViewById(R.id.button)
return v
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View file

@ -7,19 +7,33 @@ import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_restore_set.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RestoreSetFragment : Fragment() { class RestoreSetFragment : Fragment() {
private val viewModel: RestoreViewModel by sharedViewModel() private val viewModel: RestoreViewModel by sharedViewModel()
private lateinit var listView: RecyclerView
private lateinit var progressBar: ProgressBar
private lateinit var errorView: TextView
private lateinit var backView: TextView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? { savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_restore_set, container, false) val v: View = inflater.inflate(R.layout.fragment_restore_set, container, false)
listView = v.findViewById(R.id.listView)
progressBar = v.findViewById(R.id.progressBar)
errorView = v.findViewById(R.id.errorView)
backView = v.findViewById(R.id.backView)
return v
} }
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onActivityCreated(savedInstanceState: Bundle?) {

View file

@ -19,18 +19,20 @@ import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getAppName import com.stevesoltys.seedvault.ui.notification.getAppName
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_INSTALLED import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_INSTALLED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.restore.AppRestoreStatus.NOT_YET_BACKED_UP
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
@ -166,6 +168,12 @@ internal class RestoreViewModel(
private suspend fun startRestore(token: Long) { private suspend fun startRestore(token: Long) {
Log.d(TAG, "Starting new restore session to restore backup $token") Log.d(TAG, "Starting new restore session to restore backup $token")
// if we had no token before (i.e. restore from setup wizard),
// use the token of the current restore set from now on
if (settingsManager.getToken() == null) {
settingsManager.setNewToken(token)
}
// we need to start a new session and retrieve the restore sets before starting the restore // we need to start a new session and retrieve the restore sets before starting the restore
val restoreSetResult = getAvailableRestoreSets() val restoreSetResult = getAvailableRestoreSets()
if (restoreSetResult.hasError()) { if (restoreSetResult.hasError()) {
@ -212,6 +220,7 @@ internal class RestoreViewModel(
val metadata = restorableBackup.packageMetadataMap[packageName] ?: return FAILED val metadata = restorableBackup.packageMetadataMap[packageName] ?: return FAILED
return when (metadata.state) { return when (metadata.state) {
NO_DATA -> FAILED_NO_DATA NO_DATA -> FAILED_NO_DATA
WAS_STOPPED -> NOT_YET_BACKED_UP
NOT_ALLOWED -> FAILED_NOT_ALLOWED NOT_ALLOWED -> FAILED_NOT_ALLOWED
QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
UNKNOWN_ERROR -> FAILED UNKNOWN_ERROR -> FAILED
@ -296,7 +305,8 @@ internal class RestoreViewModel(
} }
} }
} }
RestoreSetResult(restorableBackups) if (restorableBackups.isEmpty()) RestoreSetResult(app.getString(R.string.restore_set_empty_result))
else RestoreSetResult(restorableBackups)
} }
} }
continuation.resume(result) continuation.resume(result)

View file

@ -5,18 +5,40 @@ import android.text.method.LinkMovementMethod
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_about.* import com.stevesoltys.seedvault.transport.backup.PackageService
import org.koin.android.ext.android.inject
class AboutDialogFragment : DialogFragment() { class AboutDialogFragment : DialogFragment() {
private val packageService: PackageService by inject()
private lateinit var versionView: TextView
private lateinit var licenseView: TextView
private lateinit var authorView: TextView
private lateinit var designView: TextView
private lateinit var sponsorView: TextView
companion object { companion object {
internal val TAG = AboutDialogFragment::class.java.simpleName internal val TAG = AboutDialogFragment::class.java.simpleName
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
return inflater.inflate(R.layout.fragment_about, container, false) savedInstanceState: Bundle?): View? {
val v: View = inflater.inflate(R.layout.fragment_about, container, false)
versionView = v.findViewById(R.id.versionView)
licenseView = v.findViewById(R.id.licenseView)
authorView = v.findViewById(R.id.authorView)
designView = v.findViewById(R.id.designView)
sponsorView = v.findViewById(R.id.sponsorView)
val versionName = packageService.getVersionName(requireContext().packageName) ?: "???"
versionView.text = getString(R.string.about_version, versionName)
return v
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View file

@ -1,12 +1,16 @@
package com.stevesoltys.seedvault.settings package com.stevesoltys.seedvault.settings
import android.content.Intent
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.View.GONE import android.view.View.GONE
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat.startActivity
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.DiffUtil.DiffResult import androidx.recyclerview.widget.DiffUtil.DiffResult
import androidx.recyclerview.widget.RecyclerView.Adapter import androidx.recyclerview.widget.RecyclerView.Adapter
@ -54,8 +58,8 @@ internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListe
fun bind(item: AppStatus) { fun bind(item: AppStatus) {
appName.text = item.name appName.text = item.name
appIcon.setImageDrawable(item.icon) appIcon.setImageDrawable(item.icon)
if (editMode) {
v.background = clickableBackground v.background = clickableBackground
if (editMode) {
v.setOnClickListener { v.setOnClickListener {
switchView.toggle() switchView.toggle()
item.enabled = switchView.isChecked item.enabled = switchView.isChecked
@ -67,8 +71,14 @@ internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListe
switchView.visibility = VISIBLE switchView.visibility = VISIBLE
switchView.isChecked = item.enabled switchView.isChecked = item.enabled
} else { } else {
v.background = null
v.setOnClickListener(null) v.setOnClickListener(null)
v.setOnLongClickListener {
val intent = Intent(ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", item.packageName, null)
}
startActivity(context, intent, null)
true
}
setStatus(item.status) setStatus(item.status)
if (item.status == SUCCEEDED) { if (item.status == SUCCEEDED) {
appInfo.text = item.time.toRelativeTime(context) appInfo.text = item.time.toRelativeTime(context)

View file

@ -9,11 +9,12 @@ import android.view.View
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ProgressBar
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_app_status.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
internal interface AppStatusToggleListener { internal interface AppStatusToggleListener {
@ -26,12 +27,20 @@ class AppStatusFragment : Fragment(), AppStatusToggleListener {
private val layoutManager = LinearLayoutManager(context) private val layoutManager = LinearLayoutManager(context)
private val adapter = AppStatusAdapter(this) private val adapter = AppStatusAdapter(this)
private lateinit var appEditMenuItem: MenuItem private lateinit var appEditMenuItem: MenuItem
private lateinit var list: RecyclerView
private lateinit var progressBar: ProgressBar
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? { savedInstanceState: Bundle?): View? {
setHasOptionsMenu(true) setHasOptionsMenu(true)
return inflater.inflate(R.layout.fragment_app_status, container, false) val v: View = inflater.inflate(R.layout.fragment_app_status, container, false)
progressBar = v.findViewById(R.id.progressBar)
list = v.findViewById(R.id.list)
return v
} }
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onActivityCreated(savedInstanceState: Bundle?) {

View file

@ -5,7 +5,7 @@ import androidx.annotation.CallSuper
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback import androidx.preference.PreferenceFragmentCompat.OnPreferenceStartFragmentCallback
import com.stevesoltys.seedvault.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel

View file

@ -70,6 +70,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
val enabled = newValue as Boolean val enabled = newValue as Boolean
try { try {
backupManager.isBackupEnabled = enabled backupManager.isBackupEnabled = enabled
if (enabled) viewModel.enableCallLogBackup()
return@OnPreferenceChangeListener true return@OnPreferenceChangeListener true
} catch (e: RemoteException) { } catch (e: RemoteException) {
e.printStackTrace() e.printStackTrace()
@ -171,6 +172,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
try { try {
backup.isChecked = backupManager.isBackupEnabled backup.isChecked = backupManager.isBackupEnabled
backup.isEnabled = true backup.isEnabled = true
// enable call log backups for existing installs (added end of 2020)
if (backup.isChecked) viewModel.enableCallLogBackup()
} catch (e: RemoteException) { } catch (e: RemoteException) {
Log.e(TAG, "Error communicating with BackupManager", e) Log.e(TAG, "Error communicating with BackupManager", e)
backup.isEnabled = false backup.isEnabled = false

View file

@ -6,8 +6,10 @@ import android.net.Uri
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import java.util.concurrent.atomic.AtomicBoolean import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import java.util.concurrent.ConcurrentSkipListSet
internal const val PREF_KEY_TOKEN = "token"
internal const val PREF_KEY_BACKUP_APK = "backup_apk" internal const val PREF_KEY_BACKUP_APK = "backup_apk"
private const val PREF_KEY_STORAGE_URI = "storageUri" private const val PREF_KEY_STORAGE_URI = "storageUri"
@ -25,10 +27,31 @@ class SettingsManager(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private var isStorageChanging: AtomicBoolean = AtomicBoolean(false) @Volatile
private var token: Long? = null
private val blacklistedApps: HashSet<String> by lazy { /**
prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet()).toHashSet() * This gets accessed by non-UI threads when saving with [PreferenceManager]
* and when [isBackupEnabled] is called during a backup run.
* Therefore, it is implemented with a thread-safe [ConcurrentSkipListSet].
*/
private val blacklistedApps: MutableSet<String> by lazy {
ConcurrentSkipListSet(prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet()))
}
fun getToken(): Long? = token ?: {
val value = prefs.getLong(PREF_KEY_TOKEN, 0L)
if (value == 0L) null else value
}()
/**
* Sets a new RestoreSet token.
* Should only be called by the [BackupCoordinator]
* to ensure that related work is performed after moving to a new token.
*/
fun setNewToken(newToken: Long) {
prefs.edit().putLong(PREF_KEY_TOKEN, newToken).apply()
token = newToken
} }
// FIXME Storage is currently plugin specific and not generic // FIXME Storage is currently plugin specific and not generic
@ -38,21 +61,17 @@ class SettingsManager(context: Context) {
.putString(PREF_KEY_STORAGE_NAME, storage.name) .putString(PREF_KEY_STORAGE_NAME, storage.name)
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb) .putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
.apply() .apply()
isStorageChanging.set(true)
} }
fun getStorage(): Storage? { fun getStorage(): Storage? {
val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null
val uri = Uri.parse(uriStr) val uri = Uri.parse(uriStr)
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null) ?: throw IllegalStateException("no storage name") val name = prefs.getString(PREF_KEY_STORAGE_NAME, null)
?: throw IllegalStateException("no storage name")
val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false) val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false)
return Storage(uri, name, isUsb) return Storage(uri, name, isUsb)
} }
fun getAndResetIsStorageChanging(): Boolean {
return isStorageChanging.getAndSet(false)
}
fun setFlashDrive(usb: FlashDrive?) { fun setFlashDrive(usb: FlashDrive?) {
if (usb == null) { if (usb == null) {
prefs.edit() prefs.edit()
@ -97,7 +116,8 @@ class SettingsManager(context: Context) {
data class Storage( data class Storage(
val uri: Uri, val uri: Uri,
val name: String, val name: String,
val isUsb: Boolean) { val isUsb: Boolean
) {
fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri) fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri)
?: throw AssertionError("Should only happen on API < 21.") ?: throw AssertionError("Should only happen on API < 21.")
} }
@ -106,7 +126,8 @@ data class FlashDrive(
val name: String, val name: String,
val serialNumber: String?, val serialNumber: String?,
val vendorId: Int, val vendorId: Int,
val productId: Int) { val productId: Int
) {
companion object { companion object {
fun from(device: UsbDevice) = FlashDrive( fun from(device: UsbDevice) = FlashDrive(
name = "${device.manufacturerName} ${device.productName}", name = "${device.manufacturerName} ${device.productName}",

View file

@ -2,7 +2,10 @@ package com.stevesoltys.seedvault.settings
import android.app.Application import android.app.Application
import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.PackageManager.NameNotFoundException
import android.provider.Settings
import android.util.Log import android.util.Log
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.core.content.ContextCompat.getDrawable import androidx.core.content.ContextCompat.getDrawable
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
@ -14,39 +17,50 @@ import androidx.recyclerview.widget.DiffUtil.calculateDiff
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getAppName
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.isSystemApp import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_WAS_STOPPED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.NOT_YET_BACKED_UP
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.transport.requestBackup import com.stevesoltys.seedvault.transport.requestBackup
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.notification.getAppName
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.* import java.util.Locale
private const val USER_FULL_DATA_BACKUP_AWARE = "user_full_data_backup_aware"
private val TAG = SettingsViewModel::class.java.simpleName private val TAG = SettingsViewModel::class.java.simpleName
class SettingsViewModel( internal class SettingsViewModel(
app: Application, app: Application,
settingsManager: SettingsManager, settingsManager: SettingsManager,
keyManager: KeyManager, keyManager: KeyManager,
private val metadataManager: MetadataManager private val notificationManager: BackupNotificationManager,
private val metadataManager: MetadataManager,
private val packageService: PackageService
) : RequireProvisioningViewModel(app, settingsManager, keyManager) { ) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
override val isRestoreOperation = false override val isRestoreOperation = false
internal val lastBackupTime = metadataManager.lastBackupTime internal val lastBackupTime = metadataManager.lastBackupTime
private val mAppStatusList = switchMap(lastBackupTime) { getAppStatusResult() } private val mAppStatusList = switchMap(lastBackupTime) {
// updates app list when lastBackupTime changes
getAppStatusResult()
}
internal val appStatusList: LiveData<AppStatusResult> = mAppStatusList internal val appStatusList: LiveData<AppStatusResult> = mAppStatusList
private val mAppEditMode = MutableLiveData<Boolean>() private val mAppEditMode = MutableLiveData<Boolean>()
@ -60,15 +74,17 @@ class SettingsViewModel(
} }
internal fun backupNow() { internal fun backupNow() {
if (notificationManager.hasActiveBackupNotifications()) {
Toast.makeText(app, R.string.notification_backup_already_running, LENGTH_LONG).show()
} else {
Thread { requestBackup(app) }.start() Thread { requestBackup(app) }.start()
} }
}
private fun getAppStatusResult(): LiveData<AppStatusResult> = liveData(Dispatchers.Main) { private fun getAppStatusResult(): LiveData<AppStatusResult> = liveData {
val pm = app.packageManager val pm = app.packageManager
val locale = Locale.getDefault() val locale = Locale.getDefault()
val list = pm.getInstalledPackages(0) val list = packageService.userApps.map {
.filter { !it.isSystemApp() }
.map {
val icon = if (it.packageName == MAGIC_PACKAGE_MANAGER) { val icon = if (it.packageName == MAGIC_PACKAGE_MANAGER) {
getDrawable(app, R.drawable.ic_launcher_default)!! getDrawable(app, R.drawable.ic_launcher_default)!!
} else { } else {
@ -83,9 +99,10 @@ class SettingsViewModel(
val status = when (metadata?.state) { val status = when (metadata?.state) {
null -> { null -> {
Log.w(TAG, "No metadata available for: ${it.packageName}") Log.w(TAG, "No metadata available for: ${it.packageName}")
FAILED NOT_YET_BACKED_UP
} }
NO_DATA -> FAILED_NO_DATA NO_DATA -> FAILED_NO_DATA
WAS_STOPPED -> FAILED_WAS_STOPPED
NOT_ALLOWED -> FAILED_NOT_ALLOWED NOT_ALLOWED -> FAILED_NOT_ALLOWED
QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED QUOTA_EXCEEDED -> FAILED_QUOTA_EXCEEDED
UNKNOWN_ERROR -> FAILED UNKNOWN_ERROR -> FAILED
@ -118,4 +135,18 @@ class SettingsViewModel(
settingsManager.onAppBackupStatusChanged(status) settingsManager.onAppBackupStatusChanged(status)
} }
/**
* Ensures that the call log will be included in backups.
*
* An AOSP code search found that call log backups get disabled if [USER_FULL_DATA_BACKUP_AWARE]
* is not set. This method sets this flag, if it is not already set.
* No other apps were found to check for this, so this should affect only call log.
*/
fun enableCallLogBackup() {
// first check if the flag is already set
if (Settings.Secure.getInt(app.contentResolver, USER_FULL_DATA_BACKUP_AWARE, 0) == 0) {
Settings.Secure.putInt(app.contentResolver, USER_FULL_DATA_BACKUP_AWARE, 1)
}
}
} }

View file

@ -9,22 +9,26 @@ import android.content.Intent
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.settings.SettingsActivity import com.stevesoltys.seedvault.settings.SettingsActivity
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import kotlinx.coroutines.runBlocking
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
import org.koin.core.inject import org.koin.core.inject
val TRANSPORT_ID: String = ConfigurableBackupTransport::class.java.name val TRANSPORT_ID: String = ConfigurableBackupTransport::class.java.name
private const val TRANSPORT_DIRECTORY_NAME = "com.stevesoltys.seedvault.transport.ConfigurableBackupTransport" private const val TRANSPORT_DIRECTORY_NAME =
"com.stevesoltys.seedvault.transport.ConfigurableBackupTransport"
private val TAG = ConfigurableBackupTransport::class.java.simpleName private val TAG = ConfigurableBackupTransport::class.java.simpleName
/** /**
* @author Steve Soltys * @author Steve Soltys
* @author Torsten Grote * @author Torsten Grote
*/ */
class ConfigurableBackupTransport internal constructor(private val context: Context) : BackupTransport(), KoinComponent { class ConfigurableBackupTransport internal constructor(private val context: Context) :
BackupTransport(), KoinComponent {
private val backupCoordinator by inject<BackupCoordinator>() private val backupCoordinator by inject<BackupCoordinator>()
private val restoreCoordinator by inject<RestoreCoordinator>() private val restoreCoordinator by inject<RestoreCoordinator>()
@ -46,35 +50,38 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
} }
override fun dataManagementLabel(): String { override fun dataManagementLabel(): String {
return "Please file a bug if you see this! 1" return context.getString(R.string.data_management_label)
} }
override fun currentDestinationString(): String { override fun currentDestinationString(): String {
return "Please file a bug if you see this! 2" return context.getString(R.string.current_destination_string)
} }
// ------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------
// General backup methods // General backup methods
// //
override fun initializeDevice(): Int { override fun initializeDevice(): Int = runBlocking {
return backupCoordinator.initializeDevice() backupCoordinator.initializeDevice()
} }
override fun isAppEligibleForBackup(targetPackage: PackageInfo, isFullBackup: Boolean): Boolean { override fun isAppEligibleForBackup(
targetPackage: PackageInfo,
isFullBackup: Boolean
): Boolean {
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup) return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
} }
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long = runBlocking {
return backupCoordinator.getBackupQuota(packageName, isFullBackup) backupCoordinator.getBackupQuota(packageName, isFullBackup)
} }
override fun clearBackupData(packageInfo: PackageInfo): Int { override fun clearBackupData(packageInfo: PackageInfo): Int = runBlocking {
return backupCoordinator.clearBackupData(packageInfo) backupCoordinator.clearBackupData(packageInfo)
} }
override fun finishBackup(): Int { override fun finishBackup(): Int = runBlocking {
return backupCoordinator.finishBackup() backupCoordinator.finishBackup()
} }
// ------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------
@ -85,11 +92,18 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
return backupCoordinator.requestBackupTime() return backupCoordinator.requestBackupTime()
} }
override fun performBackup(packageInfo: PackageInfo, inFd: ParcelFileDescriptor, flags: Int): Int { override fun performBackup(
return backupCoordinator.performIncrementalBackup(packageInfo, inFd, flags) packageInfo: PackageInfo,
inFd: ParcelFileDescriptor,
flags: Int
): Int = runBlocking {
backupCoordinator.performIncrementalBackup(packageInfo, inFd, flags)
} }
override fun performBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int { override fun performBackup(
targetPackage: PackageInfo,
fileDescriptor: ParcelFileDescriptor
): Int {
Log.w(TAG, "Warning: Legacy performBackup() method called.") Log.w(TAG, "Warning: Legacy performBackup() method called.")
return performBackup(targetPackage, fileDescriptor, 0) return performBackup(targetPackage, fileDescriptor, 0)
} }
@ -106,20 +120,27 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
return backupCoordinator.checkFullBackupSize(size) return backupCoordinator.checkFullBackupSize(size)
} }
override fun performFullBackup(targetPackage: PackageInfo, socket: ParcelFileDescriptor, flags: Int): Int { override fun performFullBackup(
return backupCoordinator.performFullBackup(targetPackage, socket, flags) targetPackage: PackageInfo,
socket: ParcelFileDescriptor,
flags: Int
): Int = runBlocking {
backupCoordinator.performFullBackup(targetPackage, socket, flags)
} }
override fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor): Int { override fun performFullBackup(
targetPackage: PackageInfo,
fileDescriptor: ParcelFileDescriptor
): Int = runBlocking {
Log.w(TAG, "Warning: Legacy performFullBackup() method called.") Log.w(TAG, "Warning: Legacy performFullBackup() method called.")
return backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0) backupCoordinator.performFullBackup(targetPackage, fileDescriptor, 0)
} }
override fun sendBackupData(numBytes: Int): Int { override fun sendBackupData(numBytes: Int): Int = runBlocking {
return backupCoordinator.sendBackupData(numBytes) backupCoordinator.sendBackupData(numBytes)
} }
override fun cancelFullBackup() { override fun cancelFullBackup() = runBlocking {
backupCoordinator.cancelFullBackup() backupCoordinator.cancelFullBackup()
} }
@ -127,8 +148,8 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
// Restore // Restore
// //
override fun getAvailableRestoreSets(): Array<RestoreSet>? { override fun getAvailableRestoreSets(): Array<RestoreSet>? = runBlocking {
return restoreCoordinator.getAvailableRestoreSets() restoreCoordinator.getAvailableRestoreSets()
} }
override fun getCurrentRestoreSet(): Long { override fun getCurrentRestoreSet(): Long {
@ -139,16 +160,16 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
return restoreCoordinator.startRestore(token, packages) return restoreCoordinator.startRestore(token, packages)
} }
override fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int { override fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int = runBlocking {
return restoreCoordinator.getNextFullRestoreDataChunk(socket) restoreCoordinator.getNextFullRestoreDataChunk(socket)
} }
override fun nextRestorePackage(): RestoreDescription? { override fun nextRestorePackage(): RestoreDescription? = runBlocking {
return restoreCoordinator.nextRestorePackage() restoreCoordinator.nextRestorePackage()
} }
override fun getRestoreData(outputFileDescriptor: ParcelFileDescriptor): Int { override fun getRestoreData(outputFileDescriptor: ParcelFileDescriptor): Int = runBlocking {
return restoreCoordinator.getRestoreData(outputFileDescriptor) restoreCoordinator.getRestoreData(outputFileDescriptor)
} }
override fun abortFullRestore(): Int { override fun abortFullRestore(): Int {

View file

@ -13,10 +13,12 @@ import android.os.RemoteException
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.BackupMonitor import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.NotificationBackupObserver
import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.notification.NotificationBackupObserver
import org.koin.core.KoinComponent
import org.koin.core.context.GlobalContext.get import org.koin.core.context.GlobalContext.get
import org.koin.core.inject
private val TAG = ConfigurableBackupTransportService::class.java.simpleName private val TAG = ConfigurableBackupTransportService::class.java.simpleName
@ -24,10 +26,12 @@ private val TAG = ConfigurableBackupTransportService::class.java.simpleName
* @author Steve Soltys * @author Steve Soltys
* @author Torsten Grote * @author Torsten Grote
*/ */
class ConfigurableBackupTransportService : Service() { class ConfigurableBackupTransportService : Service(), KoinComponent {
private var transport: ConfigurableBackupTransport? = null private var transport: ConfigurableBackupTransport? = null
private val notificationManager: BackupNotificationManager by inject()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
transport = ConfigurableBackupTransport(applicationContext) transport = ConfigurableBackupTransport(applicationContext)
@ -43,6 +47,7 @@ class ConfigurableBackupTransportService : Service() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
notificationManager.onBackupBackgroundFinished()
transport = null transport = null
Log.d(TAG, "Service destroyed.") Log.d(TAG, "Service destroyed.")
} }
@ -53,11 +58,12 @@ class ConfigurableBackupTransportService : Service() {
fun requestBackup(context: Context) { fun requestBackup(context: Context) {
val packageService: PackageService = get().koin.get() val packageService: PackageService = get().koin.get()
val packages = packageService.eligiblePackages val packages = packageService.eligiblePackages
val appTotals = packageService.expectedAppTotals
val observer = NotificationBackupObserver(context, packages.size, true) val observer = NotificationBackupObserver(context, packages.size, appTotals)
val result = try { val result = try {
val backupManager: IBackupManager = get().koin.get() val backupManager: IBackupManager = get().koin.get()
backupManager.requestBackup(packages, observer, BackupMonitor(), FLAG_USER_INITIATED) backupManager.requestBackup(packages, observer, BackupMonitor(), 0)
} catch (e: RemoteException) { } catch (e: RemoteException) {
Log.e(TAG, "Error during backup: ", e) Log.e(TAG, "Error during backup: ", e)
val nm: BackupNotificationManager = get().koin.get() val nm: BackupNotificationManager = get().koin.get()

View file

@ -11,8 +11,6 @@ import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState import com.stevesoltys.seedvault.metadata.PackageState
import com.stevesoltys.seedvault.metadata.isSystemApp
import com.stevesoltys.seedvault.metadata.isUpdatedSystemApp
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
@ -25,7 +23,8 @@ private val TAG = ApkBackup::class.java.simpleName
class ApkBackup( class ApkBackup(
private val pm: PackageManager, private val pm: PackageManager,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager) { private val metadataManager: MetadataManager
) {
/** /**
* Checks if a new APK needs to get backed up, * Checks if a new APK needs to get backed up,
@ -36,7 +35,11 @@ class ApkBackup(
* @return new [PackageMetadata] if an APK backup was made or null if no backup was made. * @return new [PackageMetadata] if an APK backup was made or null if no backup was made.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun backupApkIfNecessary(packageInfo: PackageInfo, packageState: PackageState, streamGetter: () -> OutputStream): PackageMetadata? { suspend fun backupApkIfNecessary(
packageInfo: PackageInfo,
packageState: PackageState,
streamGetter: suspend () -> OutputStream
): PackageMetadata? {
// do not back up @pm@ // do not back up @pm@
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
if (packageName == MAGIC_PACKAGE_MANAGER) return null if (packageName == MAGIC_PACKAGE_MANAGER) return null
@ -45,7 +48,7 @@ class ApkBackup(
if (!settingsManager.backupApks()) return null if (!settingsManager.backupApks()) return null
// do not back up system apps that haven't been updated // do not back up system apps that haven't been updated
if (packageInfo.isSystemApp() && !packageInfo.isUpdatedSystemApp()) { if (packageInfo.isNotUpdatedSystemApp()) {
Log.d(TAG, "Package $packageName is vanilla system app. Not backing it up.") Log.d(TAG, "Package $packageName is vanilla system app. Not backing it up.")
return null return null
} }
@ -73,7 +76,11 @@ class ApkBackup(
// do not backup if we have the version already and signatures did not change // do not backup if we have the version already and signatures did not change
if (version <= backedUpVersion && !signaturesChanged(packageMetadata, signatures)) { if (version <= backedUpVersion && !signaturesChanged(packageMetadata, signatures)) {
Log.d(TAG, "Package $packageName with version $version already has a backup ($backedUpVersion) with the same signature. Not backing it up.") Log.d(
TAG,
"Package $packageName with version $version already has a backup ($backedUpVersion)" +
" with the same signature. Not backing it up."
)
return null return null
} }
@ -91,7 +98,7 @@ class ApkBackup(
// copy the APK to the storage's output and calculate SHA-256 hash while at it // copy the APK to the storage's output and calculate SHA-256 hash while at it
val messageDigest = MessageDigest.getInstance("SHA-256") val messageDigest = MessageDigest.getInstance("SHA-256")
streamGetter.invoke().use { outputStream -> streamGetter().use { outputStream ->
inputStream.use { inputStream -> inputStream.use { inputStream ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE) val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = inputStream.read(buffer) var bytes = inputStream.read(buffer)
@ -115,7 +122,10 @@ class ApkBackup(
) )
} }
private fun signaturesChanged(packageMetadata: PackageMetadata, signatures: List<String>): Boolean { private fun signaturesChanged(
packageMetadata: PackageMetadata,
signatures: List<String>
): Boolean {
// no signatures in package metadata counts as them not having changed // no signatures in package metadata counts as them not having changed
if (packageMetadata.signatures == null) return false if (packageMetadata.signatures == null) return false
// TODO to support multiple signers check if lists differ // TODO to support multiple signers check if lists differ

View file

@ -4,12 +4,15 @@ import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
import android.app.backup.RestoreSet
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.BackupNotificationManager import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.PRIVATE
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
@ -18,8 +21,9 @@ import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.isSystemApp import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit.DAYS import java.util.concurrent.TimeUnit.DAYS
@ -29,6 +33,8 @@ private val TAG = BackupCoordinator::class.java.simpleName
* @author Steve Soltys * @author Steve Soltys
* @author Torsten Grote * @author Torsten Grote
*/ */
@WorkerThread // entire class should always be accessed from a worker thread, so blocking is ok
@Suppress("BlockingMethodInNonBlockingContext")
internal class BackupCoordinator( internal class BackupCoordinator(
private val context: Context, private val context: Context,
private val plugin: BackupPlugin, private val plugin: BackupPlugin,
@ -39,7 +45,8 @@ internal class BackupCoordinator(
private val packageService: PackageService, private val packageService: PackageService,
private val metadataManager: MetadataManager, private val metadataManager: MetadataManager,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val nm: BackupNotificationManager) { private val nm: BackupNotificationManager
) {
private var calledInitialize = false private var calledInitialize = false
private var calledClearBackupData = false private var calledClearBackupData = false
@ -49,6 +56,19 @@ internal class BackupCoordinator(
// Transport initialization and quota // Transport initialization and quota
// //
/**
* Starts a new [RestoreSet] with a new token (the current unix epoch in milliseconds).
* Call this at least once before calling [initializeDevice]
* which must be called after this method to properly initialize the backup transport.
*/
@Throws(IOException::class)
suspend fun startNewRestoreSet() {
val token = clock.time()
Log.i(TAG, "Starting new RestoreSet with token $token...")
settingsManager.setNewToken(token)
plugin.startNewRestoreSet(token)
}
/** /**
* Initialize the storage for this device, erasing all stored data. * Initialize the storage for this device, erasing all stored data.
* The transport may send the request immediately, or may buffer it. * The transport may send the request immediately, or may buffer it.
@ -67,15 +87,17 @@ internal class BackupCoordinator(
* @return One of [TRANSPORT_OK] (OK so far) or * @return One of [TRANSPORT_OK] (OK so far) or
* [TRANSPORT_ERROR] (to retry following network error or other failure). * [TRANSPORT_ERROR] (to retry following network error or other failure).
*/ */
fun initializeDevice(): Int { suspend fun initializeDevice(): Int = try {
Log.i(TAG, "Initialize Device!") val token = settingsManager.getToken()
return try { if (token == null) {
val token = clock.time() Log.i(TAG, "No RestoreSet started, initialization is no-op.")
if (plugin.initializeDevice(token)) {
Log.d(TAG, "Resetting backup metadata...")
metadataManager.onDeviceInitialization(token, plugin.getMetadataOutputStream())
} else { } else {
Log.d(TAG, "Storage was already initialized, doing no-op") Log.i(TAG, "Initialize Device!")
plugin.initializeDevice()
Log.d(TAG, "Resetting backup metadata for token $token...")
plugin.getMetadataOutputStream().use {
metadataManager.onDeviceInitialization(token, it)
}
} }
// [finishBackup] will only be called when we return [TRANSPORT_OK] here // [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully // so we remember that we initialized successfully
@ -87,9 +109,11 @@ internal class BackupCoordinator(
if (getBackupBackoff() == 0L) nm.onBackupError() if (getBackupBackoff() == 0L) nm.onBackupError()
TRANSPORT_ERROR TRANSPORT_ERROR
} }
}
fun isAppEligibleForBackup(targetPackage: PackageInfo, @Suppress("UNUSED_PARAMETER") isFullBackup: Boolean): Boolean { fun isAppEligibleForBackup(
targetPackage: PackageInfo,
@Suppress("UNUSED_PARAMETER") isFullBackup: Boolean
): Boolean {
val packageName = targetPackage.packageName val packageName = targetPackage.packageName
// Check that the app is not blacklisted by the user // Check that the app is not blacklisted by the user
val enabled = settingsManager.isBackupEnabled(packageName) val enabled = settingsManager.isBackupEnabled(packageName)
@ -107,7 +131,7 @@ internal class BackupCoordinator(
* otherwise for key-value backup. * otherwise for key-value backup.
* @return Current limit on backup size in bytes. * @return Current limit on backup size in bytes.
*/ */
fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long { suspend fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
if (packageName != MAGIC_PACKAGE_MANAGER) { if (packageName != MAGIC_PACKAGE_MANAGER) {
// try to back up APK here as later methods are sometimes not called called // try to back up APK here as later methods are sometimes not called called
backUpApk(context.packageManager.getPackageInfo(packageName, GET_SIGNING_CERTIFICATES)) backUpApk(context.packageManager.getPackageInfo(packageName, GET_SIGNING_CERTIFICATES))
@ -139,7 +163,11 @@ internal class BackupCoordinator(
Log.i(TAG, "Request incremental backup time. Returned $this") Log.i(TAG, "Request incremental backup time. Returned $this")
} }
fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int { suspend fun performIncrementalBackup(
packageInfo: PackageInfo,
data: ParcelFileDescriptor,
flags: Int
): Int {
cancelReason = UNKNOWN_ERROR cancelReason = UNKNOWN_ERROR
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
if (packageName == MAGIC_PACKAGE_MANAGER) { if (packageName == MAGIC_PACKAGE_MANAGER) {
@ -148,10 +176,13 @@ internal class BackupCoordinator(
if (getBackupBackoff() != 0L) { if (getBackupBackoff() != 0L) {
return TRANSPORT_PACKAGE_REJECTED return TRANSPORT_PACKAGE_REJECTED
} }
}
val result = kv.performBackup(packageInfo, data, flags)
if (result == TRANSPORT_OK && packageName == MAGIC_PACKAGE_MANAGER) {
// hook in here to back up APKs of apps that are otherwise not allowed for backup // hook in here to back up APKs of apps that are otherwise not allowed for backup
backUpNotAllowedPackages() backUpNotAllowedPackages()
} }
return kv.performBackup(packageInfo, data, flags) return result
} }
// ------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------
@ -182,12 +213,16 @@ internal class BackupCoordinator(
return result return result
} }
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int): Int { suspend fun performFullBackup(
targetPackage: PackageInfo,
fileDescriptor: ParcelFileDescriptor,
flags: Int
): Int {
cancelReason = UNKNOWN_ERROR cancelReason = UNKNOWN_ERROR
return full.performFullBackup(targetPackage, fileDescriptor, flags) return full.performFullBackup(targetPackage, fileDescriptor, flags)
} }
fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes) suspend fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes)
/** /**
* Tells the transport to cancel the currently-ongoing full backup operation. * Tells the transport to cancel the currently-ongoing full backup operation.
@ -202,7 +237,7 @@ internal class BackupCoordinator(
* If the transport receives this callback, it will *not* receive a call to [finishBackup]. * If the transport receives this callback, it will *not* receive a call to [finishBackup].
* It needs to tear down any ongoing backup state here. * It needs to tear down any ongoing backup state here.
*/ */
fun cancelFullBackup() { suspend fun cancelFullBackup() {
val packageInfo = full.getCurrentPackage() val packageInfo = full.getCurrentPackage()
?: throw AssertionError("Cancelling full backup, but no current package") ?: throw AssertionError("Cancelling full backup, but no current package")
Log.i(TAG, "Cancel full backup of ${packageInfo.packageName} because of $cancelReason") Log.i(TAG, "Cancel full backup of ${packageInfo.packageName} because of $cancelReason")
@ -221,7 +256,7 @@ internal class BackupCoordinator(
* *
* @return the same error codes as [performFullBackup]. * @return the same error codes as [performFullBackup].
*/ */
fun clearBackupData(packageInfo: PackageInfo): Int { suspend fun clearBackupData(packageInfo: PackageInfo): Int {
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
Log.i(TAG, "Clear Backup Data of $packageName.") Log.i(TAG, "Clear Backup Data of $packageName.")
try { try {
@ -248,7 +283,7 @@ internal class BackupCoordinator(
* *
* @return the same error codes as [performIncrementalBackup] or [performFullBackup]. * @return the same error codes as [performIncrementalBackup] or [performFullBackup].
*/ */
fun finishBackup(): Int = when { suspend fun finishBackup(): Int = when {
kv.hasState() -> { kv.hasState() -> {
check(!full.hasState()) { "K/V backup has state, but full backup has dangling state as well" } check(!full.hasState()) { "K/V backup has state, but full backup has dangling state as well" }
onPackageBackedUp(kv.getCurrentPackage()!!) // not-null because we have state onPackageBackedUp(kv.getCurrentPackage()!!) // not-null because we have state
@ -267,48 +302,76 @@ internal class BackupCoordinator(
else -> throw IllegalStateException("Unexpected state in finishBackup()") else -> throw IllegalStateException("Unexpected state in finishBackup()")
} }
private fun backUpNotAllowedPackages() { @VisibleForTesting(otherwise = PRIVATE)
internal suspend fun backUpNotAllowedPackages() {
Log.d(TAG, "Checking if APKs of opt-out apps need backup...") Log.d(TAG, "Checking if APKs of opt-out apps need backup...")
packageService.notAllowedPackages.forEach { optOutPackageInfo -> val notAllowedPackages = packageService.notAllowedPackages
notAllowedPackages.forEachIndexed { i, packageInfo ->
val packageName = packageInfo.packageName
try { try {
backUpApk(optOutPackageInfo, NOT_ALLOWED) nm.onOptOutAppBackup(packageName, i + 1, notAllowedPackages.size)
val packageState = if (packageInfo.isStopped()) WAS_STOPPED else NOT_ALLOWED
val wasBackedUp = backUpApk(packageInfo, packageState)
if (!wasBackedUp) {
val packageMetadata = metadataManager.getPackageMetadata(packageName)
val oldPackageState = packageMetadata?.state
if (oldPackageState != null && oldPackageState != packageState) {
Log.e(TAG, "Package $packageName was in $oldPackageState, update to $packageState")
plugin.getMetadataOutputStream().use {
metadataManager.onPackageBackupError(packageInfo, packageState, it)
}
}
}
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error backing up opt-out APK of ${optOutPackageInfo.packageName}", e) Log.e(TAG, "Error backing up opt-out APK of $packageName", e)
} }
} }
} }
private fun backUpApk(packageInfo: PackageInfo, packageState: PackageState = UNKNOWN_ERROR) { /**
* Backs up an APK for the given [PackageInfo].
*
* @return true if a backup was performed and false if no backup was needed or it failed.
*/
private suspend fun backUpApk(
packageInfo: PackageInfo,
packageState: PackageState = UNKNOWN_ERROR
): Boolean {
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
try { return try {
apkBackup.backupApkIfNecessary(packageInfo, packageState) { apkBackup.backupApkIfNecessary(packageInfo, packageState) {
plugin.getApkOutputStream(packageInfo) plugin.getApkOutputStream(packageInfo)
}?.let { packageMetadata -> }?.let { packageMetadata ->
val outputStream = plugin.getMetadataOutputStream() plugin.getMetadataOutputStream().use {
metadataManager.onApkBackedUp(packageInfo, packageMetadata, outputStream) metadataManager.onApkBackedUp(packageInfo, packageMetadata, it)
} }
true
} ?: false
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error while writing APK or metadata for $packageName", e) Log.e(TAG, "Error while writing APK or metadata for $packageName", e)
false
} }
} }
private fun onPackageBackedUp(packageInfo: PackageInfo) { private suspend fun onPackageBackedUp(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
try { try {
val outputStream = plugin.getMetadataOutputStream() plugin.getMetadataOutputStream().use {
metadataManager.onPackageBackedUp(packageInfo, outputStream) metadataManager.onPackageBackedUp(packageInfo, it)
}
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error while writing metadata for $packageName", e) Log.e(TAG, "Error while writing metadata for $packageName", e)
} }
} }
private fun onPackageBackupError(packageInfo: PackageInfo) { private suspend fun onPackageBackupError(packageInfo: PackageInfo) {
// don't bother with system apps that have no data // don't bother with system apps that have no data
if (cancelReason == NO_DATA && packageInfo.isSystemApp()) return if (cancelReason == NO_DATA && packageInfo.isSystemApp()) return
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
try { try {
val outputStream = plugin.getMetadataOutputStream() plugin.getMetadataOutputStream().use {
metadataManager.onPackageBackupError(packageInfo, cancelReason, outputStream) metadataManager.onPackageBackupError(packageInfo, cancelReason, it)
}
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error while writing metadata for $packageName", e) Log.e(TAG, "Error while writing metadata for $packageName", e)
} }

View file

@ -5,9 +5,9 @@ import org.koin.dsl.module
val backupModule = module { val backupModule = module {
single { InputFactory() } single { InputFactory() }
single { PackageService(androidContext().packageManager, get()) } single { PackageService(androidContext(), get()) }
single { ApkBackup(androidContext().packageManager, get(), get()) } single { ApkBackup(androidContext().packageManager, get(), get()) }
single { KVBackup(get<BackupPlugin>().kvBackupPlugin, get(), get(), get()) } single { KVBackup(get<BackupPlugin>().kvBackupPlugin, get(), get(), get(), get()) }
single { FullBackup(get<BackupPlugin>().fullBackupPlugin, get(), get(), get()) } single { FullBackup(get<BackupPlugin>().fullBackupPlugin, get(), get(), get()) }
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get(), get()) }
} }

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.transport.backup package com.stevesoltys.seedvault.transport.backup
import android.app.backup.RestoreSet
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
@ -11,25 +12,30 @@ interface BackupPlugin {
val fullBackupPlugin: FullBackupPlugin val fullBackupPlugin: FullBackupPlugin
/** /**
* Initialize the storage for this device, erasing all stored data. * Start a new [RestoreSet] with the given token.
* *
* @return true if the device needs initialization or * This is typically followed by a call to [initializeDevice].
* false if the device was initialized already and initialization should be a no-op.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun initializeDevice(newToken: Long): Boolean suspend fun startNewRestoreSet(token: Long)
/**
* Initialize the storage for this device, erasing all stored data in the current [RestoreSet].
*/
@Throws(IOException::class)
suspend fun initializeDevice()
/** /**
* Returns an [OutputStream] for writing backup metadata. * Returns an [OutputStream] for writing backup metadata.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun getMetadataOutputStream(): OutputStream suspend fun getMetadataOutputStream(): OutputStream
/** /**
* Returns an [OutputStream] for writing an APK to be backed up. * Returns an [OutputStream] for writing an APK to be backed up.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun getApkOutputStream(packageInfo: PackageInfo): OutputStream suspend fun getApkOutputStream(packageInfo: PackageInfo): OutputStream
/** /**
* Returns the package name of the app that provides the backend storage * Returns the package name of the app that provides the backend storage

View file

@ -21,7 +21,8 @@ private class FullBackupState(
internal val packageInfo: PackageInfo, internal val packageInfo: PackageInfo,
internal val inputFileDescriptor: ParcelFileDescriptor, internal val inputFileDescriptor: ParcelFileDescriptor,
internal val inputStream: InputStream, internal val inputStream: InputStream,
internal var outputStreamInit: (() -> OutputStream)?) { internal var outputStreamInit: (suspend () -> OutputStream)?
) {
internal var outputStream: OutputStream? = null internal var outputStream: OutputStream? = null
internal val packageName: String = packageInfo.packageName internal val packageName: String = packageInfo.packageName
internal var size: Long = 0 internal var size: Long = 0
@ -31,11 +32,13 @@ const val DEFAULT_QUOTA_FULL_BACKUP = (2 * (25 * 1024 * 1024)).toLong()
private val TAG = FullBackup::class.java.simpleName private val TAG = FullBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class FullBackup( internal class FullBackup(
private val plugin: FullBackupPlugin, private val plugin: FullBackupPlugin,
private val inputFactory: InputFactory, private val inputFactory: InputFactory,
private val headerWriter: HeaderWriter, private val headerWriter: HeaderWriter,
private val crypto: Crypto) { private val crypto: Crypto
) {
private var state: FullBackupState? = null private var state: FullBackupState? = null
@ -89,7 +92,11 @@ internal class FullBackup(
* [TRANSPORT_OK] to indicate that the OS may proceed with delivering backup data; * [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. * [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 { suspend fun performFullBackup(
targetPackage: PackageInfo,
socket: ParcelFileDescriptor,
@Suppress("UNUSED_PARAMETER") flags: Int = 0
): Int {
if (state != null) throw AssertionError() if (state != null) throw AssertionError()
Log.i(TAG, "Perform full backup for ${targetPackage.packageName}.") Log.i(TAG, "Perform full backup for ${targetPackage.packageName}.")
@ -101,7 +108,9 @@ internal class FullBackup(
val outputStream = try { val outputStream = try {
plugin.getOutputStream(targetPackage) plugin.getOutputStream(targetPackage)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error getting OutputStream for full backup of ${targetPackage.packageName}", e) "Error getting OutputStream for full backup of ${targetPackage.packageName}".let {
Log.e(TAG, it, e)
}
throw(e) throw(e)
} }
// store version header // store version header
@ -119,7 +128,7 @@ internal class FullBackup(
return TRANSPORT_OK return TRANSPORT_OK
} }
fun sendBackupData(numBytes: Int): Int { suspend fun sendBackupData(numBytes: Int): Int {
val state = this.state val state = this.state
?: throw AssertionError("Attempted sendBackupData before performFullBackup") ?: throw AssertionError("Attempted sendBackupData before performFullBackup")
@ -127,18 +136,23 @@ internal class FullBackup(
state.size += numBytes state.size += numBytes
val quota = plugin.getQuota() val quota = plugin.getQuota()
if (state.size > quota) { if (state.size > quota) {
Log.w(TAG, "Full backup of additional $numBytes exceeds quota of $quota with ${state.size}.") Log.w(
TAG,
"Full backup of additional $numBytes exceeds quota of $quota with ${state.size}."
)
return TRANSPORT_QUOTA_EXCEEDED return TRANSPORT_QUOTA_EXCEEDED
} }
return try { return try {
// get output stream or initialize it, if it does not yet exist // get output stream or initialize it, if it does not yet exist
check((state.outputStream != null) xor (state.outputStreamInit != null)) { "No OutputStream xor no StreamGetter" } check((state.outputStream != null) xor (state.outputStreamInit != null)) {
val outputStream = state.outputStream ?: { "No OutputStream xor no StreamGetter"
val stream = state.outputStreamInit!!.invoke() // not-null due to check above }
val outputStream = state.outputStream ?: suspend {
val stream = state.outputStreamInit!!() // not-null due to check above
state.outputStream = stream state.outputStream = stream
stream stream
}.invoke() }()
state.outputStreamInit = null // the stream init lambda is not needed beyond that point state.outputStreamInit = null // the stream init lambda is not needed beyond that point
// read backup data, encrypt it and write it to output stream // read backup data, encrypt it and write it to output stream
@ -152,11 +166,11 @@ internal class FullBackup(
} }
@Throws(IOException::class) @Throws(IOException::class)
fun clearBackupData(packageInfo: PackageInfo) { suspend fun clearBackupData(packageInfo: PackageInfo) {
plugin.removeDataOfPackage(packageInfo) plugin.removeDataOfPackage(packageInfo)
} }
fun cancelFullBackup() { suspend fun cancelFullBackup() {
Log.i(TAG, "Cancel full backup") Log.i(TAG, "Cancel full backup")
val state = this.state ?: throw AssertionError("No state when canceling") val state = this.state ?: throw AssertionError("No state when canceling")
try { try {

View file

@ -10,12 +10,12 @@ interface FullBackupPlugin {
// TODO consider using a salted hash for the package name to not leak it to the storage server // TODO consider using a salted hash for the package name to not leak it to the storage server
@Throws(IOException::class) @Throws(IOException::class)
fun getOutputStream(targetPackage: PackageInfo): OutputStream suspend fun getOutputStream(targetPackage: PackageInfo): OutputStream
/** /**
* Remove all data associated with the given package. * Remove all data associated with the given package.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun removeDataOfPackage(packageInfo: PackageInfo) suspend fun removeDataOfPackage(packageInfo: PackageInfo)
} }

View file

@ -8,6 +8,8 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.encodeBase64 import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.header.HeaderWriter import com.stevesoltys.seedvault.header.HeaderWriter
@ -21,11 +23,14 @@ const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
private val TAG = KVBackup::class.java.simpleName private val TAG = KVBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVBackup( internal class KVBackup(
private val plugin: KVBackupPlugin, private val plugin: KVBackupPlugin,
private val inputFactory: InputFactory, private val inputFactory: InputFactory,
private val headerWriter: HeaderWriter, private val headerWriter: HeaderWriter,
private val crypto: Crypto) { private val crypto: Crypto,
private val nm: BackupNotificationManager
) {
private var state: KVBackupState? = null private var state: KVBackupState? = null
@ -35,7 +40,11 @@ internal class KVBackup(
fun getQuota(): Long = plugin.getQuota() fun getQuota(): Long = plugin.getQuota()
fun performBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int { suspend fun performBackup(
packageInfo: PackageInfo,
data: ParcelFileDescriptor,
flags: Int
): Int {
val isIncremental = flags and FLAG_INCREMENTAL != 0 val isIncremental = flags and FLAG_INCREMENTAL != 0
val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0 val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
@ -64,7 +73,10 @@ internal class KVBackup(
return backupError(TRANSPORT_ERROR) return backupError(TRANSPORT_ERROR)
} }
if (isIncremental && !hasDataForPackage) { if (isIncremental && !hasDataForPackage) {
Log.w(TAG, "Requested incremental, but transport currently stores no data $packageName, requesting non-incremental retry.") Log.w(
TAG, "Requested incremental, but transport currently stores no data" +
" for $packageName, requesting non-incremental retry."
)
return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED) return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
} }
@ -79,46 +91,78 @@ internal class KVBackup(
} }
} }
// 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 // parse and store the K/V updates
return storeRecords(packageInfo, data) return storeRecords(packageInfo, data)
} }
private fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int { private suspend fun storeRecords(packageInfo: PackageInfo, data: ParcelFileDescriptor): Int {
val backupSequence: Iterable<Result<KVOperation>>
val pmRecordNumber: Int?
if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER) {
// Since the package manager has many small keys to store,
// and this can be slow, especially on cloud-based storage,
// we get the entire data set first, so we can show progress notifications.
val list = parseBackupStream(data).toList()
backupSequence = list
pmRecordNumber = list.size
} else {
backupSequence = parseBackupStream(data).asIterable()
pmRecordNumber = null
}
// apply the delta operations // apply the delta operations
for (result in parseBackupStream(data)) { var i = 1
for (result in backupSequence) {
if (result is Result.Error) { if (result is Result.Error) {
Log.e(TAG, "Exception reading backup input", result.exception) Log.e(TAG, "Exception reading backup input", result.exception)
return backupError(TRANSPORT_ERROR) return backupError(TRANSPORT_ERROR)
} }
val op = (result as Result.Ok).result val op = (result as Result.Ok).result
try { try {
storeRecord(packageInfo, op, i++, pmRecordNumber)
} catch (e: IOException) {
Log.e(TAG, "Unable to update base64Key file for base64Key ${op.base64Key}", e)
// Returning something more forgiving such as TRANSPORT_PACKAGE_REJECTED
// will still make the entire backup fail.
// TODO However, TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED might buy us a retry,
// we would just need to be careful not to create an infinite loop
// for permanent errors.
return backupError(TRANSPORT_ERROR)
}
}
return TRANSPORT_OK
}
@Throws(IOException::class)
private suspend fun storeRecord(
packageInfo: PackageInfo,
op: KVOperation,
currentNum: Int,
pmRecordNumber: Int?
) {
// update notification for package manager backup
if (pmRecordNumber != null) {
nm.onPmKvBackup(op.key, currentNum, pmRecordNumber)
}
// check if record should get deleted
if (op.value == null) { if (op.value == null) {
Log.e(TAG, "Deleting record with base64Key ${op.base64Key}") Log.e(TAG, "Deleting record with base64Key ${op.base64Key}")
plugin.deleteRecord(packageInfo, op.base64Key) plugin.deleteRecord(packageInfo, op.base64Key)
} else { } else {
val outputStream = plugin.getOutputStreamForRecord(packageInfo, op.base64Key) val outputStream = plugin.getOutputStreamForRecord(packageInfo, op.base64Key)
val header = VersionHeader(packageName = packageInfo.packageName, key = op.key) try {
val header = VersionHeader(
packageName = packageInfo.packageName,
key = op.key
)
headerWriter.writeVersion(outputStream, header) headerWriter.writeVersion(outputStream, header)
crypto.encryptHeader(outputStream, header) crypto.encryptHeader(outputStream, header)
crypto.encryptMultipleSegments(outputStream, op.value) crypto.encryptMultipleSegments(outputStream, op.value)
outputStream.flush() outputStream.flush()
} finally {
closeQuietly(outputStream) 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 * Parses a backup stream into individual key/value operations
@ -163,12 +207,13 @@ internal class KVBackup(
} }
@Throws(IOException::class) @Throws(IOException::class)
fun clearBackupData(packageInfo: PackageInfo) { suspend fun clearBackupData(packageInfo: PackageInfo) {
plugin.removeDataOfPackage(packageInfo) plugin.removeDataOfPackage(packageInfo)
} }
fun finishBackup(): Int { fun finishBackup(): Int {
Log.i(TAG, "Finish K/V Backup of ${state!!.packageInfo.packageName}") Log.i(TAG, "Finish K/V Backup of ${state!!.packageInfo.packageName}")
plugin.packageFinished(state!!.packageInfo)
state = null state = null
return TRANSPORT_OK return TRANSPORT_OK
} }
@ -178,18 +223,21 @@ internal class KVBackup(
* because [finishBackup] is not called when we don't return [TRANSPORT_OK]. * because [finishBackup] is not called when we don't return [TRANSPORT_OK].
*/ */
private fun backupError(result: Int): Int { private fun backupError(result: Int): Int {
Log.i(TAG, "Resetting state because of K/V Backup error of ${state!!.packageInfo.packageName}") "Resetting state because of K/V Backup error of ${state!!.packageInfo.packageName}".let {
Log.i(TAG, it)
}
plugin.packageFinished(state!!.packageInfo)
state = null state = null
return result return result
} }
private class KVOperation( private class KVOperation(
internal val key: String, val key: String,
internal val base64Key: String, val base64Key: String,
/** /**
* value is null when this is a deletion operation * value is null when this is a deletion operation
*/ */
internal val value: ByteArray? val value: ByteArray?
) )
private sealed class Result<out T> { private sealed class Result<out T> {

View file

@ -14,36 +14,38 @@ interface KVBackupPlugin {
// TODO consider using a salted hash for the package name (and key) to not leak it to the storage server // TODO consider using a salted hash for the package name (and key) to not leak it to the storage server
/** /**
* Return true if there are records stored for the given package. * Return true if there are records stored for the given package.
*/ * This is always called first per [PackageInfo], before subsequent methods.
@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. * Independent of the return value, the storage should now be prepared to store K/V pairs.
* E.g. file-based plugins should a create a directory for the package, if none exists. * E.g. file-based plugins should a create a directory for the package, if none exists.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun ensureRecordStorageForPackage(packageInfo: PackageInfo) suspend fun hasDataForPackage(packageInfo: PackageInfo): Boolean
/** /**
* Return an [OutputStream] for the given package and key * Return an [OutputStream] for the given package and key
* which will receive the record's encrypted value. * which will receive the record's encrypted value.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream suspend fun getOutputStreamForRecord(packageInfo: PackageInfo, key: String): OutputStream
/** /**
* Delete the record for the given package identified by the given key. * Delete the record for the given package identified by the given key.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun deleteRecord(packageInfo: PackageInfo, key: String) suspend fun deleteRecord(packageInfo: PackageInfo, key: String)
/** /**
* Remove all data associated with the given package. * Remove all data associated with the given package,
* but be prepared to receive new records afterwards with [getOutputStreamForRecord].
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun removeDataOfPackage(packageInfo: PackageInfo) suspend fun removeDataOfPackage(packageInfo: PackageInfo)
/**
* The package finished backup.
* This can be an opportunity to clear existing caches or to do other clean-up work.
*/
fun packageFinished(packageInfo: PackageInfo)
} }

View file

@ -1,8 +1,14 @@
package com.stevesoltys.seedvault.transport.backup package com.stevesoltys.seedvault.transport.backup
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
import android.content.Context
import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP
import android.content.pm.ApplicationInfo.FLAG_STOPPED
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_INSTRUMENTATION
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.RemoteException import android.os.RemoteException
import android.os.UserHandle import android.os.UserHandle
@ -20,9 +26,11 @@ private const val LOG_MAX_PACKAGES = 100
* @author Torsten Grote * @author Torsten Grote
*/ */
internal class PackageService( internal class PackageService(
private val packageManager: PackageManager, private val context: Context,
private val backupManager: IBackupManager) { private val backupManager: IBackupManager
) {
private val packageManager: PackageManager = context.packageManager
private val myUserId = UserHandle.myUserId() private val myUserId = UserHandle.myUserId()
val eligiblePackages: Array<String> val eligiblePackages: Array<String>
@ -41,14 +49,13 @@ internal class PackageService(
} }
} }
val eligibleApps = backupManager.filterAppsEligibleForBackupForUser(myUserId, packages.toTypedArray()) val eligibleApps =
backupManager.filterAppsEligibleForBackupForUser(myUserId, packages.toTypedArray())
// log eligible packages // log eligible packages
if (Log.isLoggable(TAG, INFO)) { if (Log.isLoggable(TAG, INFO)) {
Log.i(TAG, "Filtering left ${eligibleApps.size} eligible packages:") Log.i(TAG, "Filtering left ${eligibleApps.size} eligible packages:")
eligibleApps.toList().chunked(LOG_MAX_PACKAGES).forEach { logPackages(eligibleApps.toList())
Log.i(TAG, it.toString())
}
} }
// add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data // add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data
@ -61,16 +68,103 @@ internal class PackageService(
val notAllowedPackages: List<PackageInfo> val notAllowedPackages: List<PackageInfo>
@WorkerThread @WorkerThread
get() { get() {
val installed = packageManager.getInstalledPackages(GET_SIGNING_CERTIFICATES) // We need the GET_SIGNING_CERTIFICATES flag here,
val installedArray = installed.map { packageInfo -> // because the package info is used by [ApkBackup] which needs signing info.
return packageManager.getInstalledPackages(GET_SIGNING_CERTIFICATES)
.filter { packageInfo ->
packageInfo.doesNotGetBackedUp() && // only apps that do not allow backup
!packageInfo.isNotUpdatedSystemApp() && // and are not vanilla system apps
packageInfo.packageName != context.packageName // not this app
}.sortedBy { packageInfo ->
packageInfo.packageName packageInfo.packageName
}.toTypedArray() }.also { notAllowed ->
// log eligible packages
if (Log.isLoggable(TAG, INFO)) {
Log.i(TAG, "${notAllowed.size} apps do not allow backup:")
logPackages(notAllowed.map { it.packageName })
}
}
}
val eligible = backupManager.filterAppsEligibleForBackupForUser(myUserId, installedArray) /**
* A list of non-system apps (without instrumentation test apps).
*/
val userApps: List<PackageInfo>
@WorkerThread
get() {
return packageManager.getInstalledPackages(GET_INSTRUMENTATION)
.filter { it.isUserVisible(context) }
}
return installed.filter { packageInfo -> val expectedAppTotals: ExpectedAppTotals
packageInfo.packageName !in eligible @WorkerThread
}.sortedBy { it.packageName } get() {
var appsTotal = 0
var appsOptOut = 0
packageManager.getInstalledPackages(GET_INSTRUMENTATION).forEach { packageInfo ->
if (packageInfo.isUserVisible(context)) {
appsTotal++
if (packageInfo.doesNotGetBackedUp()) {
appsOptOut++
}
}
}
return ExpectedAppTotals(appsTotal, appsOptOut)
}
fun getVersionName(packageName: String): String? = try {
packageManager.getPackageInfo(packageName, 0).versionName
} catch (e: PackageManager.NameNotFoundException) {
null
}
private fun logPackages(packages: List<String>) {
packages.chunked(LOG_MAX_PACKAGES).forEach {
Log.i(TAG, it.toString())
}
} }
} }
internal data class ExpectedAppTotals(
/**
* The total number of non-system apps eligible for backup.
*/
val appsTotal: Int,
/**
* The number of non-system apps that has opted-out of backup.
*/
val appsOptOut: Int
)
internal fun PackageInfo.isUserVisible(context: Context): Boolean {
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false
return !isNotUpdatedSystemApp() && instrumentation == null && packageName != context.packageName
}
internal fun PackageInfo.isSystemApp(): Boolean {
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true
return applicationInfo.flags and FLAG_SYSTEM != 0
}
/**
* Returns true if this is a system app that hasn't been updated.
* We don't back up those APKs.
*/
internal fun PackageInfo.isNotUpdatedSystemApp(): Boolean {
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true
val isSystemApp = applicationInfo.flags and FLAG_SYSTEM != 0
val isUpdatedSystemApp = applicationInfo.flags and FLAG_UPDATED_SYSTEM_APP != 0
return isSystemApp && !isUpdatedSystemApp
}
internal fun PackageInfo.doesNotGetBackedUp(): Boolean {
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true
return applicationInfo.flags and FLAG_ALLOW_BACKUP == 0 || // does not allow backup
applicationInfo.flags and FLAG_STOPPED != 0 // is stopped
}
internal fun PackageInfo.isStopped(): Boolean {
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false
return applicationInfo.flags and FLAG_STOPPED != 0
}

View file

@ -9,8 +9,8 @@ import android.util.Log
import com.stevesoltys.seedvault.encodeBase64 import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.metadata.isSystemApp
import com.stevesoltys.seedvault.transport.backup.getSignatures import com.stevesoltys.seedvault.transport.backup.getSignatures
import com.stevesoltys.seedvault.transport.backup.isSystemApp
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.FAILED import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.FAILED
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.IN_PROGRESS import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED import com.stevesoltys.seedvault.transport.restore.ApkRestoreStatus.QUEUED
@ -137,7 +137,7 @@ internal class ApkRestore(
if (isOlder || !installedPackageInfo.isSystemApp()) throw NameNotFoundException() if (isOlder || !installedPackageInfo.isSystemApp()) throw NameNotFoundException()
} catch (e: NameNotFoundException) { } catch (e: NameNotFoundException) {
Log.w(TAG, "Not installing $packageName because older or not a system app here.") Log.w(TAG, "Not installing $packageName because older or not a system app here.")
fail(installResult, packageName) emit(fail(installResult, packageName))
return@flow return@flow
} }
} }

View file

@ -23,6 +23,7 @@ private class FullRestoreState(
private val TAG = FullRestore::class.java.simpleName private val TAG = FullRestore::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class FullRestore( internal class FullRestore(
private val plugin: FullRestorePlugin, private val plugin: FullRestorePlugin,
private val outputFactory: OutputFactory, private val outputFactory: OutputFactory,
@ -37,7 +38,7 @@ internal class FullRestore(
* Return true if there is data stored for the given package. * Return true if there is data stored for the given package.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
return plugin.hasDataForPackage(token, packageInfo) return plugin.hasDataForPackage(token, packageInfo)
} }
@ -78,7 +79,7 @@ internal class FullRestore(
* Any other negative value such as [TRANSPORT_ERROR] is treated as a fatal error condition * 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. * that aborts all further restore operations on the current dataset.
*/ */
fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int { suspend fun getNextFullRestoreDataChunk(socket: ParcelFileDescriptor): Int {
val state = this.state ?: throw IllegalStateException("no state") val state = this.state ?: throw IllegalStateException("no state")
val packageName = state.packageInfo.packageName val packageName = state.packageInfo.packageName
@ -113,6 +114,7 @@ internal class FullRestore(
try { try {
// read segment from input stream and decrypt it // read segment from input stream and decrypt it
val decrypted = try { val decrypted = try {
// TODO handle IOException
crypto.decryptSegment(inputStream) crypto.decryptSegment(inputStream)
} catch (e: EOFException) { } catch (e: EOFException) {
Log.i(TAG, " EOF") Log.i(TAG, " EOF")

View file

@ -10,9 +10,9 @@ interface FullRestorePlugin {
* Return true if there is data stored for the given package. * Return true if there is data stored for the given package.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
@Throws(IOException::class) @Throws(IOException::class)
fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream suspend fun getInputStreamForPackage(token: Long, packageInfo: PackageInfo): InputStream
} }

View file

@ -15,7 +15,7 @@ import com.stevesoltys.seedvault.header.HeaderReader
import com.stevesoltys.seedvault.header.UnsupportedVersionException import com.stevesoltys.seedvault.header.UnsupportedVersionException
import libcore.io.IoUtils.closeQuietly import libcore.io.IoUtils.closeQuietly
import java.io.IOException import java.io.IOException
import java.util.* import java.util.ArrayList
import javax.crypto.AEADBadTagException import javax.crypto.AEADBadTagException
private class KVRestoreState( private class KVRestoreState(
@ -24,15 +24,18 @@ private class KVRestoreState(
/** /**
* Optional [PackageInfo] for single package restore, optimizes restore of @pm@ * Optional [PackageInfo] for single package restore, optimizes restore of @pm@
*/ */
internal val pmPackageInfo: PackageInfo?) internal val pmPackageInfo: PackageInfo?
)
private val TAG = KVRestore::class.java.simpleName private val TAG = KVRestore::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVRestore( internal class KVRestore(
private val plugin: KVRestorePlugin, private val plugin: KVRestorePlugin,
private val outputFactory: OutputFactory, private val outputFactory: OutputFactory,
private val headerReader: HeaderReader, private val headerReader: HeaderReader,
private val crypto: Crypto) { private val crypto: Crypto
) {
private var state: KVRestoreState? = null private var state: KVRestoreState? = null
@ -40,7 +43,7 @@ internal class KVRestore(
* Return true if there are records stored for the given package. * Return true if there are records stored for the given package.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean { suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean {
return plugin.hasDataForPackage(token, packageInfo) return plugin.hasDataForPackage(token, packageInfo)
} }
@ -63,7 +66,7 @@ internal class KVRestore(
* @return One of [TRANSPORT_OK] * @return One of [TRANSPORT_OK]
* or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled). * or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled).
*/ */
fun getRestoreData(data: ParcelFileDescriptor): Int { suspend fun getRestoreData(data: ParcelFileDescriptor): Int {
val state = this.state ?: throw IllegalStateException("no state") val state = this.state ?: throw IllegalStateException("no state")
// The restore set is the concatenation of the individual record blobs, // The restore set is the concatenation of the individual record blobs,
@ -109,7 +112,7 @@ internal class KVRestore(
* Return a list of the records (represented by key files) in the given directory, * 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. * sorted lexically by the Base64-decoded key file name, not by the on-disk filename.
*/ */
private fun getSortedKeys(token: Long, packageInfo: PackageInfo): List<DecodedKey>? { private suspend fun getSortedKeys(token: Long, packageInfo: PackageInfo): List<DecodedKey>? {
val records: List<String> = try { val records: List<String> = try {
plugin.listRecords(token, packageInfo) plugin.listRecords(token, packageInfo)
} catch (e: IOException) { } catch (e: IOException) {
@ -122,7 +125,8 @@ internal class KVRestore(
for (recordKey in records) contents.add(DecodedKey(recordKey)) for (recordKey in records) contents.add(DecodedKey(recordKey))
// remove keys that are not needed for single package @pm@ restore // remove keys that are not needed for single package @pm@ restore
val pmPackageName = state?.pmPackageInfo?.packageName val pmPackageName = state?.pmPackageInfo?.packageName
val sortedKeys = if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER && pmPackageName != null) { val sortedKeys =
if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER && pmPackageName != null) {
val keys = listOf(ANCESTRAL_RECORD_KEY, GLOBAL_METADATA_KEY, pmPackageName) val keys = listOf(ANCESTRAL_RECORD_KEY, GLOBAL_METADATA_KEY, pmPackageName)
Log.d(TAG, "Single package restore, restrict restore keys to $pmPackageName") Log.d(TAG, "Single package restore, restrict restore keys to $pmPackageName")
contents.filterTo(ArrayList()) { it.key in keys } contents.filterTo(ArrayList()) { it.key in keys }
@ -135,9 +139,12 @@ internal class KVRestore(
* Read the encrypted value for the given key and write it to the given [BackupDataOutput]. * Read the encrypted value for the given key and write it to the given [BackupDataOutput].
*/ */
@Throws(IOException::class, UnsupportedVersionException::class, SecurityException::class) @Throws(IOException::class, UnsupportedVersionException::class, SecurityException::class)
private fun readAndWriteValue(state: KVRestoreState, dKey: DecodedKey, out: BackupDataOutput) { private suspend fun readAndWriteValue(
val inputStream = plugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key) state: KVRestoreState,
try { dKey: DecodedKey,
out: BackupDataOutput
) = plugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key)
.use { inputStream ->
val version = headerReader.readVersion(inputStream) val version = headerReader.readVersion(inputStream)
crypto.decryptHeader(inputStream, version, state.packageInfo.packageName, dKey.key) crypto.decryptHeader(inputStream, version, state.packageInfo.packageName, dKey.key)
val value = crypto.decryptMultipleSegments(inputStream) val value = crypto.decryptMultipleSegments(inputStream)
@ -146,9 +153,7 @@ internal class KVRestore(
out.writeEntityHeader(dKey.key, size) out.writeEntityHeader(dKey.key, size)
out.writeEntityData(value, size) out.writeEntityData(value, size)
} finally { Unit
closeQuietly(inputStream)
}
} }
private class DecodedKey(internal val base64Key: String) : Comparable<DecodedKey> { private class DecodedKey(internal val base64Key: String) : Comparable<DecodedKey> {

View file

@ -10,21 +10,26 @@ interface KVRestorePlugin {
* Return true if there is data stored for the given package. * Return true if there is data stored for the given package.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean suspend fun hasDataForPackage(token: Long, packageInfo: PackageInfo): Boolean
/** /**
* Return all record keys for the given token and package. * Return all record keys for the given token and package.
* *
* Note: Implementations usually expect that you call [hasDataForPackage]
* with the same parameters before.
*
* For file-based plugins, this is usually a list of file names in the package directory. * For file-based plugins, this is usually a list of file names in the package directory.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun listRecords(token: Long, packageInfo: PackageInfo): List<String> suspend fun listRecords(token: Long, packageInfo: PackageInfo): List<String>
/** /**
* Return an [InputStream] for the given token, package and key * Return an [InputStream] for the given token, package and key
* which will provide the record's encrypted value. * which will provide the record's encrypted value.
*
* Note: Implementations might expect that you call [hasDataForPackage] before.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream suspend fun getInputStreamForRecord(token: Long, packageInfo: PackageInfo, key: String): InputStream
} }

View file

@ -13,7 +13,6 @@ import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.header.UnsupportedVersionException import com.stevesoltys.seedvault.header.UnsupportedVersionException
@ -22,21 +21,24 @@ import com.stevesoltys.seedvault.metadata.DecryptionFailedException
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.MetadataReader import com.stevesoltys.seedvault.metadata.MetadataReader
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import libcore.io.IoUtils.closeQuietly import libcore.io.IoUtils.closeQuietly
import java.io.IOException import java.io.IOException
private class RestoreCoordinatorState( private data class RestoreCoordinatorState(
internal val token: Long, val token: Long,
internal val packages: Iterator<PackageInfo>, val packages: Iterator<PackageInfo>,
/** /**
* Optional [PackageInfo] for single package restore, to reduce data needed to read for @pm@ * Optional [PackageInfo] for single package restore, to reduce data needed to read for @pm@
*/ */
internal val pmPackageInfo: PackageInfo?) { val pmPackageInfo: PackageInfo?
internal var currentPackage: String? = null ) {
var currentPackage: String? = null
} }
private val TAG = RestoreCoordinator::class.java.simpleName private val TAG = RestoreCoordinator::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class RestoreCoordinator( internal class RestoreCoordinator(
private val context: Context, private val context: Context,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
@ -45,7 +47,8 @@ internal class RestoreCoordinator(
private val plugin: RestorePlugin, private val plugin: RestorePlugin,
private val kv: KVRestore, private val kv: KVRestore,
private val full: FullRestore, private val full: FullRestore,
private val metadataReader: MetadataReader) { private val metadataReader: MetadataReader
) {
private var state: RestoreCoordinatorState? = null private var state: RestoreCoordinatorState? = null
private var backupMetadata: LongSparseArray<BackupMetadata>? = null private var backupMetadata: LongSparseArray<BackupMetadata>? = null
@ -57,7 +60,7 @@ internal class RestoreCoordinator(
* @return Descriptions of the set of restore images available for this device, * @return Descriptions of the set of restore images available for this device,
* or null if an error occurred (the attempt should be rescheduled). * or null if an error occurred (the attempt should be rescheduled).
**/ **/
fun getAvailableRestoreSets(): Array<RestoreSet>? { suspend fun getAvailableRestoreSets(): Array<RestoreSet>? {
val availableBackups = plugin.getAvailableBackups() ?: return null val availableBackups = plugin.getAvailableBackups() ?: return null
val restoreSets = ArrayList<RestoreSet>() val restoreSets = ArrayList<RestoreSet>()
val metadataMap = LongSparseArray<BackupMetadata>() val metadataMap = LongSparseArray<BackupMetadata>()
@ -67,7 +70,10 @@ internal class RestoreCoordinator(
"No error when getting encrypted metadata, but stream is still missing." "No error when getting encrypted metadata, but stream is still missing."
} }
try { try {
val metadata = metadataReader.readMetadata(encryptedMetadata.inputStream, encryptedMetadata.token) val metadata = metadataReader.readMetadata(
encryptedMetadata.inputStream,
encryptedMetadata.token
)
metadataMap.put(encryptedMetadata.token, metadata) metadataMap.put(encryptedMetadata.token, metadata)
val set = RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token) val set = RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token)
restoreSets.add(set) restoreSets.add(set)
@ -100,8 +106,9 @@ internal class RestoreCoordinator(
* or 0 if there is no backup set available corresponding to the current device state. * or 0 if there is no backup set available corresponding to the current device state.
*/ */
fun getCurrentRestoreSet(): Long { fun getCurrentRestoreSet(): Long {
return metadataManager.getBackupToken() return (settingsManager.getToken() ?: 0L).apply {
.apply { Log.i(TAG, "Got current restore set token: $this") } Log.i(TAG, "Got current restore set token: $this")
}
} }
/** /**
@ -117,11 +124,12 @@ internal class RestoreCoordinator(
* or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled). * or [TRANSPORT_ERROR] (an error occurred, the restore should be aborted and rescheduled).
*/ */
fun startRestore(token: Long, packages: Array<out PackageInfo>): Int { fun startRestore(token: Long, packages: Array<out PackageInfo>): Int {
check(state == null) { "Started new restore with existing state" } check(state == null) { "Started new restore with existing state: $state" }
Log.i(TAG, "Start restore with ${packages.map { info -> info.packageName }}") Log.i(TAG, "Start restore with ${packages.map { info -> info.packageName }}")
// If there's only one package to restore (Auto Restore feature), add it to the state // If there's only one package to restore (Auto Restore feature), add it to the state
val pmPackageInfo = if (packages.size == 2 && packages[0].packageName == MAGIC_PACKAGE_MANAGER) { val pmPackageInfo =
if (packages.size == 2 && packages[0].packageName == MAGIC_PACKAGE_MANAGER) {
val pmPackageName = packages[1].packageName val pmPackageName = packages[1].packageName
Log.d(TAG, "Optimize for single package restore of $pmPackageName") Log.d(TAG, "Optimize for single package restore of $pmPackageName")
// check if the backup is on removable storage that is not plugged in // check if the backup is on removable storage that is not plugged in
@ -131,7 +139,10 @@ internal class RestoreCoordinator(
// remind user to plug in storage device // remind user to plug in storage device
val storageName = settingsManager.getStorage()?.name val storageName = settingsManager.getStorage()?.name
?: context.getString(R.string.settings_backup_location_none) ?: context.getString(R.string.settings_backup_location_none)
notificationManager.onRemovableStorageNotAvailableForRestore(pmPackageName, storageName) notificationManager.onRemovableStorageNotAvailableForRestore(
pmPackageName,
storageName
)
} }
return TRANSPORT_ERROR return TRANSPORT_ERROR
} }
@ -169,7 +180,7 @@ internal class RestoreCoordinator(
* or [NO_MORE_PACKAGES] to indicate that no more packages can be restored in this session; * or [NO_MORE_PACKAGES] to indicate that no more packages can be restored in this session;
* or null to indicate a transport-level error. * or null to indicate a transport-level error.
*/ */
fun nextRestorePackage(): RestoreDescription? { suspend fun nextRestorePackage(): RestoreDescription? {
Log.i(TAG, "Next restore package!") Log.i(TAG, "Next restore package!")
val state = this.state ?: throw IllegalStateException("no state") val state = this.state ?: throw IllegalStateException("no state")
@ -213,7 +224,7 @@ internal class RestoreCoordinator(
* @param data An open, writable file into which the key/value backup data should be stored. * @param data An open, writable file into which the key/value backup data should be stored.
* @return the same error codes as [startRestore]. * @return the same error codes as [startRestore].
*/ */
fun getRestoreData(data: ParcelFileDescriptor): Int { suspend fun getRestoreData(data: ParcelFileDescriptor): Int {
return kv.getRestoreData(data).apply { return kv.getRestoreData(data).apply {
if (this != TRANSPORT_OK) { if (this != TRANSPORT_OK) {
// add current package to failed ones // add current package to failed ones
@ -228,7 +239,7 @@ internal class RestoreCoordinator(
* After this method returns zero, the system will then call [nextRestorePackage] * 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. * to begin the restore process for the next application, and the sequence begins again.
*/ */
fun getNextFullRestoreDataChunk(outputFileDescriptor: ParcelFileDescriptor): Int { suspend fun getNextFullRestoreDataChunk(outputFileDescriptor: ParcelFileDescriptor): Int {
return full.getNextFullRestoreDataChunk(outputFileDescriptor) return full.getNextFullRestoreDataChunk(outputFileDescriptor)
} }
@ -240,6 +251,7 @@ internal class RestoreCoordinator(
* or will call [finishRestore] to shut down the restore operation. * or will call [finishRestore] to shut down the restore operation.
*/ */
fun abortFullRestore(): Int { fun abortFullRestore(): Int {
Log.d(TAG, "abortFullRestore")
state?.currentPackage?.let { failedPackages.add(it) } state?.currentPackage?.let { failedPackages.add(it) }
return full.abortFullRestore() return full.abortFullRestore()
} }
@ -249,7 +261,9 @@ internal class RestoreCoordinator(
* freeing any resources and connections used during the restore process. * freeing any resources and connections used during the restore process.
*/ */
fun finishRestore() { fun finishRestore() {
Log.d(TAG, "finishRestore")
if (full.hasState()) full.finishRestore() if (full.hasState()) full.finishRestore()
state = null
} }
/** /**

View file

@ -18,7 +18,7 @@ interface RestorePlugin {
* @return metadata for the set of restore images available, * @return metadata for the set of restore images available,
* or null if an error occurred (the attempt should be rescheduled). * or null if an error occurred (the attempt should be rescheduled).
**/ **/
fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>? suspend fun getAvailableBackups(): Sequence<EncryptedBackupMetadata>?
/** /**
* Searches if there's really a backup available in the given location. * Searches if there's really a backup available in the given location.
@ -27,12 +27,13 @@ interface RestorePlugin {
* FIXME: Passing a Uri is maybe too plugin-specific? * FIXME: Passing a Uri is maybe too plugin-specific?
*/ */
@WorkerThread @WorkerThread
fun hasBackup(uri: Uri): Boolean @Throws(IOException::class)
suspend fun hasBackup(uri: Uri): Boolean
/** /**
* Returns an [InputStream] for the given token, for reading an APK that is to be restored. * Returns an [InputStream] for the given token, for reading an APK that is to be restored.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun getApkInputStream(token: Long, packageName: String): InputStream suspend fun getApkInputStream(token: Long, packageName: String): InputStream
} }

View file

@ -18,11 +18,12 @@ import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_ALLOWED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_INSTALLED import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NOT_INSTALLED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_NO_DATA
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.FAILED_WAS_STOPPED
import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS import com.stevesoltys.seedvault.restore.AppRestoreStatus.IN_PROGRESS
import com.stevesoltys.seedvault.restore.AppRestoreStatus.NOT_YET_BACKED_UP
import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED import com.stevesoltys.seedvault.restore.AppRestoreStatus.SUCCEEDED
internal abstract class AppViewHolder(protected val v: View) : RecyclerView.ViewHolder(v) {
internal open class AppViewHolder(protected val v: View) : RecyclerView.ViewHolder(v) {
protected val context: Context = v.context protected val context: Context = v.context
protected val pm: PackageManager = context.packageManager protected val pm: PackageManager = context.packageManager
@ -41,7 +42,6 @@ internal open class AppViewHolder(protected val v: View) : RecyclerView.ViewHold
} }
protected fun setStatus(status: AppRestoreStatus) { protected fun setStatus(status: AppRestoreStatus) {
v.background = null
if (status == IN_PROGRESS) { if (status == IN_PROGRESS) {
appInfo.visibility = GONE appInfo.visibility = GONE
appStatus.visibility = INVISIBLE appStatus.visibility = INVISIBLE
@ -63,7 +63,9 @@ internal open class AppViewHolder(protected val v: View) : RecyclerView.ViewHold
} }
private fun AppRestoreStatus.getInfo(): String = when (this) { private fun AppRestoreStatus.getInfo(): String = when (this) {
NOT_YET_BACKED_UP -> context.getString(R.string.restore_app_not_yet_backed_up)
FAILED_NO_DATA -> context.getString(R.string.restore_app_no_data) FAILED_NO_DATA -> context.getString(R.string.restore_app_no_data)
FAILED_WAS_STOPPED -> context.getString(R.string.restore_app_was_stopped)
FAILED_NOT_ALLOWED -> context.getString(R.string.restore_app_not_allowed) FAILED_NOT_ALLOWED -> context.getString(R.string.restore_app_not_allowed)
FAILED_NOT_INSTALLED -> context.getString(R.string.restore_app_not_installed) FAILED_NOT_INSTALLED -> context.getString(R.string.restore_app_not_installed)
FAILED_QUOTA_EXCEEDED -> context.getString(R.string.restore_app_quota_exceeded) FAILED_QUOTA_EXCEEDED -> context.getString(R.string.restore_app_quota_exceeded)

View file

@ -1,4 +1,4 @@
package com.stevesoltys.seedvault package com.stevesoltys.seedvault.ui.notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@ -10,16 +10,20 @@ import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.PackageManager.NameNotFoundException
import android.util.Log
import androidx.core.app.NotificationCompat.Action import androidx.core.app.NotificationCompat.Action
import androidx.core.app.NotificationCompat.Builder import androidx.core.app.NotificationCompat.Builder
import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT import androidx.core.app.NotificationCompat.PRIORITY_DEFAULT
import androidx.core.app.NotificationCompat.PRIORITY_HIGH import androidx.core.app.NotificationCompat.PRIORITY_HIGH
import androidx.core.app.NotificationCompat.PRIORITY_LOW import androidx.core.app.NotificationCompat.PRIORITY_LOW
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.ACTION_RESTORE_ERROR_UNINSTALL import com.stevesoltys.seedvault.restore.ACTION_RESTORE_ERROR_UNINSTALL
import com.stevesoltys.seedvault.restore.EXTRA_PACKAGE_NAME import com.stevesoltys.seedvault.restore.EXTRA_PACKAGE_NAME
import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
import com.stevesoltys.seedvault.settings.SettingsActivity import com.stevesoltys.seedvault.settings.SettingsActivity
import com.stevesoltys.seedvault.transport.backup.ExpectedAppTotals
private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver" private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
private const val CHANNEL_ID_ERROR = "NotificationError" private const val CHANNEL_ID_ERROR = "NotificationError"
@ -27,14 +31,21 @@ private const val CHANNEL_ID_RESTORE_ERROR = "NotificationRestoreError"
private const val NOTIFICATION_ID_OBSERVER = 1 private const val NOTIFICATION_ID_OBSERVER = 1
private const val NOTIFICATION_ID_ERROR = 2 private const val NOTIFICATION_ID_ERROR = 2
private const val NOTIFICATION_ID_RESTORE_ERROR = 3 private const val NOTIFICATION_ID_RESTORE_ERROR = 3
private const val NOTIFICATION_ID_BACKGROUND = 4
class BackupNotificationManager(private val context: Context) { private val TAG = BackupNotificationManager::class.java.simpleName
internal class BackupNotificationManager(private val context: Context) {
private val nm = context.getSystemService(NotificationManager::class.java)!!.apply { private val nm = context.getSystemService(NotificationManager::class.java)!!.apply {
createNotificationChannel(getObserverChannel()) createNotificationChannel(getObserverChannel())
createNotificationChannel(getErrorChannel()) createNotificationChannel(getErrorChannel())
createNotificationChannel(getRestoreErrorChannel()) createNotificationChannel(getRestoreErrorChannel())
} }
private var expectedApps: Int? = null
private var expectedOptOutApps: Int? = null
private var expectedPmRecords: Int? = null
private var expectedAppTotals: ExpectedAppTotals? = null
private fun getObserverChannel(): NotificationChannel { private fun getObserverChannel(): NotificationChannel {
val title = context.getString(R.string.notification_channel_title) val title = context.getString(R.string.notification_channel_title)
@ -53,32 +64,124 @@ class BackupNotificationManager(private val context: Context) {
return NotificationChannel(CHANNEL_ID_RESTORE_ERROR, title, IMPORTANCE_HIGH) return NotificationChannel(CHANNEL_ID_RESTORE_ERROR, title, IMPORTANCE_HIGH)
} }
fun onBackupUpdate(app: CharSequence, transferred: Int, expected: Int, userInitiated: Boolean) { /**
* Call this right after starting a backup.
*
* We can not know [expectedPmRecords] here, because this number varies between backup runs
* and is only known when the system tells us to update [MAGIC_PACKAGE_MANAGER].
*/
fun onBackupStarted(
expectedPackages: Int,
appTotals: ExpectedAppTotals
) {
updateBackupNotification(
infoText = "", // This passes quickly, no need to show something here
transferred = 0,
expected = expectedPackages
)
expectedApps = expectedPackages
expectedOptOutApps = appTotals.appsOptOut
expectedAppTotals = appTotals
}
/**
* This is expected to get called before [onOptOutAppBackup] and [onBackupUpdate].
*/
fun onPmKvBackup(packageName: String, transferred: Int, expected: Int) {
val text = "@pm@ record for $packageName"
if (expectedApps == null) {
updateBackgroundBackupNotification(text)
} else {
val addend = (expectedOptOutApps ?: 0) + (expectedApps ?: 0)
updateBackupNotification(
infoText = text,
transferred = transferred,
expected = expected + addend
)
expectedPmRecords = expected
}
}
/**
* This should get called after [onPmKvBackup], but before [onBackupUpdate].
*/
fun onOptOutAppBackup(packageName: String, transferred: Int, expected: Int) {
val text = "Opt-out APK for $packageName"
if (expectedApps == null) {
updateBackgroundBackupNotification(text)
} else {
updateBackupNotification(
infoText = text,
transferred = transferred + (expectedPmRecords ?: 0),
expected = expected + (expectedApps ?: 0) + (expectedPmRecords ?: 0)
)
expectedOptOutApps = expected
}
}
/**
* In the series of notification updates,
* this type is is expected to get called after [onOptOutAppBackup] and [onPmKvBackup].
*/
fun onBackupUpdate(app: CharSequence, transferred: Int) {
val expected = expectedApps ?: error("expectedApps is null")
val addend = (expectedOptOutApps ?: 0) + (expectedPmRecords ?: 0)
updateBackupNotification(
infoText = app,
transferred = transferred + addend,
expected = expected + addend
)
}
private fun updateBackupNotification(
infoText: CharSequence,
transferred: Int,
expected: Int
) {
@Suppress("MagicNumber")
val percentage = (transferred.toFloat() / expected) * 100
val percentageStr = "%.0f%%".format(percentage)
Log.i(TAG, "$transferred/$expected - $percentageStr - $infoText")
val notification = Builder(context, CHANNEL_ID_OBSERVER).apply { val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
setSmallIcon(R.drawable.ic_cloud_upload) setSmallIcon(R.drawable.ic_cloud_upload)
setContentTitle(context.getString(R.string.notification_title)) setContentTitle(context.getString(R.string.notification_title))
setContentText(app) setContentText(percentageStr)
setOngoing(true) setOngoing(true)
setShowWhen(false) setShowWhen(false)
setWhen(System.currentTimeMillis()) setWhen(System.currentTimeMillis())
setProgress(expected, transferred, false) setProgress(expected, transferred, false)
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW priority = PRIORITY_DEFAULT
}.build() }.build()
nm.notify(NOTIFICATION_ID_OBSERVER, notification) nm.notify(NOTIFICATION_ID_OBSERVER, notification)
} }
fun onBackupFinished(success: Boolean, notBackedUp: Int?, userInitiated: Boolean) { private fun updateBackgroundBackupNotification(infoText: CharSequence) {
if (!userInitiated) { Log.i(TAG, "$infoText")
nm.cancel(NOTIFICATION_ID_OBSERVER) val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
return setSmallIcon(R.drawable.ic_cloud_upload)
setContentTitle(context.getString(R.string.notification_title))
setShowWhen(false)
setWhen(System.currentTimeMillis())
setProgress(0, 0, true)
priority = PRIORITY_LOW
}.build()
nm.notify(NOTIFICATION_ID_BACKGROUND, notification)
} }
val titleRes = if (success) R.string.notification_success_title else R.string.notification_failed_title
val contentText = if (notBackedUp == null) null else { fun onBackupBackgroundFinished() {
context.getString(R.string.notification_success_num_not_backed_up, notBackedUp) nm.cancel(NOTIFICATION_ID_BACKGROUND)
}
fun onBackupFinished(success: Boolean, numBackedUp: Int?) {
val titleRes =
if (success) R.string.notification_success_title else R.string.notification_failed_title
val total = expectedAppTotals?.appsTotal
val contentText = if (numBackedUp == null || total == null) null else {
context.getString(R.string.notification_success_text, numBackedUp, total)
} }
val iconRes = if (success) R.drawable.ic_cloud_done else R.drawable.ic_cloud_error val iconRes = if (success) R.drawable.ic_cloud_done else R.drawable.ic_cloud_error
val intent = Intent(context, SettingsActivity::class.java).apply { val intent = Intent(context, SettingsActivity::class.java).apply {
action = ACTION_APP_STATUS_LIST if (success) action = ACTION_APP_STATUS_LIST
} }
val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0) val pendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
val notification = Builder(context, CHANNEL_ID_OBSERVER).apply { val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
@ -94,6 +197,20 @@ class BackupNotificationManager(private val context: Context) {
priority = PRIORITY_LOW priority = PRIORITY_LOW
}.build() }.build()
nm.notify(NOTIFICATION_ID_OBSERVER, notification) nm.notify(NOTIFICATION_ID_OBSERVER, notification)
// reset number of expected apps
expectedOptOutApps = null
expectedPmRecords = null
expectedApps = null
expectedAppTotals = null
}
fun hasActiveBackupNotifications(): Boolean {
nm.activeNotifications.forEach {
if (it.packageName == context.packageName &&
(it.id == NOTIFICATION_ID_OBSERVER || it.id == NOTIFICATION_ID_BACKGROUND)
) return true
}
return false
} }
fun onBackupError() { fun onBackupError() {
@ -128,7 +245,8 @@ class BackupNotificationManager(private val context: Context) {
setPackage(context.packageName) setPackage(context.packageName)
putExtra(EXTRA_PACKAGE_NAME, packageName) putExtra(EXTRA_PACKAGE_NAME, packageName)
} }
val pendingIntent = PendingIntent.getBroadcast(context, REQUEST_CODE_UNINSTALL, intent, FLAG_UPDATE_CURRENT) val pendingIntent =
PendingIntent.getBroadcast(context, REQUEST_CODE_UNINSTALL, intent, FLAG_UPDATE_CURRENT)
val actionText = context.getString(R.string.notification_restore_error_action) val actionText = context.getString(R.string.notification_restore_error_action)
val action = Action(R.drawable.ic_warning, actionText, pendingIntent) val action = Action(R.drawable.ic_warning, actionText, pendingIntent)
val notification = Builder(context, CHANNEL_ID_RESTORE_ERROR).apply { val notification = Builder(context, CHANNEL_ID_RESTORE_ERROR).apply {

View file

@ -1,4 +1,4 @@
package com.stevesoltys.seedvault package com.stevesoltys.seedvault.ui.notification
import android.app.backup.BackupProgress import android.app.backup.BackupProgress
import android.app.backup.IBackupObserver import android.app.backup.IBackupObserver
@ -7,16 +7,20 @@ import android.content.pm.PackageManager.NameNotFoundException
import android.util.Log import android.util.Log
import android.util.Log.INFO import android.util.Log.INFO
import android.util.Log.isLoggable import android.util.Log.isLoggable
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.transport.backup.ExpectedAppTotals
import org.koin.core.KoinComponent import org.koin.core.KoinComponent
import org.koin.core.inject import org.koin.core.inject
private val TAG = NotificationBackupObserver::class.java.simpleName private val TAG = NotificationBackupObserver::class.java.simpleName
class NotificationBackupObserver( internal class NotificationBackupObserver(
private val context: Context, private val context: Context,
private val expectedPackages: Int, private val expectedPackages: Int,
private val userInitiated: Boolean) : IBackupObserver.Stub(), KoinComponent { appTotals: ExpectedAppTotals
) : IBackupObserver.Stub(), KoinComponent {
private val nm: BackupNotificationManager by inject() private val nm: BackupNotificationManager by inject()
private val metadataManager: MetadataManager by inject() private val metadataManager: MetadataManager by inject()
@ -24,14 +28,18 @@ class NotificationBackupObserver(
private var numPackages: Int = 0 private var numPackages: Int = 0
init { init {
// we need to show this manually as [onUpdate] isn't called for first @pm@ package // Inform the notification manager that a backup has started
nm.onBackupUpdate(getAppName(MAGIC_PACKAGE_MANAGER), 0, expectedPackages, userInitiated) // and inform about the expected numbers, so it can compute a total.
nm.onBackupStarted(expectedPackages, appTotals)
} }
/** /**
* This method could be called several times for packages with full data backup. * This method could be called several times for packages with full data backup.
* It will tell how much of backup data is already saved and how much is expected. * It will tell how much of backup data is already saved and how much is expected.
* *
* Note that this will not be called for [MAGIC_PACKAGE_MANAGER]
* which is usually the first package to get backed up.
*
* @param currentBackupPackage The name of the package that now being backed up. * @param currentBackupPackage The name of the package that now being backed up.
* @param backupProgress Current progress of backup for the package. * @param backupProgress Current progress of backup for the package.
*/ */
@ -69,20 +77,22 @@ class NotificationBackupObserver(
Log.i(TAG, "Backup finished $numPackages/$expectedPackages. Status: $status") Log.i(TAG, "Backup finished $numPackages/$expectedPackages. Status: $status")
} }
val success = status == 0 val success = status == 0
val notBackedUp = if (success) metadataManager.getPackagesNumNotBackedUp() else null val numBackedUp = if (success) metadataManager.getPackagesNumBackedUp() else null
nm.onBackupFinished(success, notBackedUp, userInitiated) nm.onBackupFinished(success, numBackedUp)
} }
private fun showProgressNotification(packageName: String) { private fun showProgressNotification(packageName: String) {
if (currentPackage == packageName) return if (currentPackage == packageName) return
if (isLoggable(TAG, INFO)) { if (isLoggable(TAG, INFO)) {
Log.i(TAG, "Showing progress notification for $currentPackage $numPackages/$expectedPackages") "Showing progress notification for $currentPackage $numPackages/$expectedPackages".let {
Log.i(TAG, it)
}
} }
currentPackage = packageName currentPackage = packageName
val app = getAppName(packageName) val app = getAppName(packageName)
numPackages += 1 numPackages += 1
nm.onBackupUpdate(app, numPackages, expectedPackages, userInitiated) nm.onBackupUpdate(app, numPackages)
} }
private fun getAppName(packageId: String): CharSequence = getAppName(context, packageId) private fun getAppName(packageId: String): CharSequence = getAppName(context, packageId)
@ -90,7 +100,9 @@ class NotificationBackupObserver(
} }
fun getAppName(context: Context, packageId: String): CharSequence { fun getAppName(context: Context, packageId: String): CharSequence {
if (packageId == MAGIC_PACKAGE_MANAGER) return context.getString(R.string.restore_magic_package) if (packageId == MAGIC_PACKAGE_MANAGER || packageId.startsWith("@")) {
return context.getString(R.string.restore_magic_package)
}
return try { return try {
val appInfo = context.packageManager.getApplicationInfo(packageId, 0) val appInfo = context.packageManager.getApplicationInfo(packageId, 0)
context.packageManager.getApplicationLabel(appInfo) ?: packageId context.packageManager.getApplicationLabel(appInfo) ?: packageId

View file

@ -8,25 +8,63 @@ import android.view.View.VISIBLE
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView import android.widget.AutoCompleteTextView
import android.widget.Button
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import android.widget.Toast.LENGTH_LONG import android.widget.Toast.LENGTH_LONG
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.google.android.material.textfield.TextInputLayout
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.isDebugBuild import com.stevesoltys.seedvault.isDebugBuild
import io.github.novacrypto.bip39.Validation.InvalidChecksumException import io.github.novacrypto.bip39.Validation.InvalidChecksumException
import io.github.novacrypto.bip39.Validation.WordNotFoundException import io.github.novacrypto.bip39.Validation.WordNotFoundException
import io.github.novacrypto.bip39.wordlists.English import io.github.novacrypto.bip39.wordlists.English
import kotlinx.android.synthetic.main.fragment_recovery_code_input.*
import kotlinx.android.synthetic.main.recovery_code_input.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RecoveryCodeInputFragment : Fragment() { class RecoveryCodeInputFragment : Fragment() {
private val viewModel: RecoveryCodeViewModel by sharedViewModel() private val viewModel: RecoveryCodeViewModel by sharedViewModel()
private lateinit var introText: TextView
private lateinit var doneButton: Button
private lateinit var backView: TextView
private lateinit var wordLayout1: TextInputLayout
private lateinit var wordLayout2: TextInputLayout
private lateinit var wordLayout3: TextInputLayout
private lateinit var wordLayout4: TextInputLayout
private lateinit var wordLayout5: TextInputLayout
private lateinit var wordLayout6: TextInputLayout
private lateinit var wordLayout7: TextInputLayout
private lateinit var wordLayout8: TextInputLayout
private lateinit var wordLayout9: TextInputLayout
private lateinit var wordLayout10: TextInputLayout
private lateinit var wordLayout11: TextInputLayout
private lateinit var wordLayout12: TextInputLayout
private lateinit var wordList: ConstraintLayout
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? { savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_recovery_code_input, container, false) val v: View = inflater.inflate(R.layout.fragment_recovery_code_input, container, false)
introText = v.findViewById(R.id.introText)
doneButton = v.findViewById(R.id.doneButton)
backView = v.findViewById(R.id.backView)
wordLayout1 = v.findViewById(R.id.wordLayout1)
wordLayout2 = v.findViewById(R.id.wordLayout2)
wordLayout3 = v.findViewById(R.id.wordLayout3)
wordLayout4 = v.findViewById(R.id.wordLayout4)
wordLayout5 = v.findViewById(R.id.wordLayout5)
wordLayout6 = v.findViewById(R.id.wordLayout6)
wordLayout7 = v.findViewById(R.id.wordLayout7)
wordLayout8 = v.findViewById(R.id.wordLayout8)
wordLayout9 = v.findViewById(R.id.wordLayout9)
wordLayout10 = v.findViewById(R.id.wordLayout10)
wordLayout11 = v.findViewById(R.id.wordLayout11)
wordLayout12 = v.findViewById(R.id.wordLayout12)
wordList = v.findViewById(R.id.wordList)
return v
} }
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onActivityCreated(savedInstanceState: Bundle?) {

View file

@ -5,20 +5,28 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Button
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_recovery_code_output.*
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RecoveryCodeOutputFragment : Fragment() { class RecoveryCodeOutputFragment : Fragment() {
private val viewModel: RecoveryCodeViewModel by sharedViewModel() private val viewModel: RecoveryCodeViewModel by sharedViewModel()
private lateinit var wordList: RecyclerView
private lateinit var confirmCodeButton: Button
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? { savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_recovery_code_output, container, false) val v: View = inflater.inflate(R.layout.fragment_recovery_code_output, container, false)
wordList = v.findViewById(R.id.wordList)
confirmCodeButton = v.findViewById(R.id.confirmCodeButton)
return v
} }
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onActivityCreated(savedInstanceState: Bundle?) {

View file

@ -8,35 +8,51 @@ import android.net.Uri
import android.os.UserHandle import android.os.UserHandle
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.TRANSPORT_ID import com.stevesoltys.seedvault.transport.TRANSPORT_ID
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.transport.requestBackup import com.stevesoltys.seedvault.transport.requestBackup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.IOException
private val TAG = BackupStorageViewModel::class.java.simpleName private val TAG = BackupStorageViewModel::class.java.simpleName
internal class BackupStorageViewModel( internal class BackupStorageViewModel(
private val app: Application, private val app: Application,
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
settingsManager: SettingsManager) : StorageViewModel(app, settingsManager) { private val backupCoordinator: BackupCoordinator,
settingsManager: SettingsManager
) : StorageViewModel(app, settingsManager) {
override val isRestoreOperation = false override val isRestoreOperation = false
override fun onLocationSet(uri: Uri) { override fun onLocationSet(uri: Uri) {
val isUsb = saveStorage(uri) val isUsb = saveStorage(uri)
viewModelScope.launch(Dispatchers.IO) {
try {
// will also generate a new backup token for the new restore set
backupCoordinator.startNewRestoreSet()
// initialize the new location, will also generate a new backup token // initialize the new location
val observer = InitializationObserver() backupManager.initializeTransportsForUser(
backupManager.initializeTransportsForUser(UserHandle.myUserId(), arrayOf(TRANSPORT_ID), observer) UserHandle.myUserId(),
arrayOf(TRANSPORT_ID),
// if storage is on USB and this is not SetupWizard, do a backup right away // if storage is on USB and this is not SetupWizard, do a backup right away
if (isUsb && !isSetupWizard) Thread { InitializationObserver(isUsb && !isSetupWizard)
requestBackup(app) )
}.start() } catch (e: IOException) {
Log.e(TAG, "Error starting new RestoreSet", e)
onInitializationError()
}
}
} }
@WorkerThread @WorkerThread
private inner class InitializationObserver : IBackupObserver.Stub() { private inner class InitializationObserver(val requestBackup: Boolean) :
IBackupObserver.Stub() {
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) { override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
// noop // noop
} }
@ -52,12 +68,19 @@ internal class BackupStorageViewModel(
if (status == 0) { if (status == 0) {
// notify the UI that the location has been set // notify the UI that the location has been set
mLocationChecked.postEvent(LocationResult()) mLocationChecked.postEvent(LocationResult())
if (requestBackup) {
requestBackup(app)
}
} else { } else {
// notify the UI that the location was invalid // notify the UI that the location was invalid
val errorMsg = app.getString(R.string.storage_check_fragment_backup_error) onInitializationError()
mLocationChecked.postEvent(LocationResult(errorMsg))
} }
} }
} }
private fun onInitializationError() {
val errorMsg = app.getString(R.string.storage_check_fragment_backup_error)
mLocationChecked.postEvent(LocationResult(errorMsg))
}
} }

View file

@ -3,10 +3,14 @@ package com.stevesoltys.seedvault.ui.storage
import android.app.Application import android.app.Application
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.restore.RestorePlugin import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.IOException
private val TAG = RestoreStorageViewModel::class.java.simpleName private val TAG = RestoreStorageViewModel::class.java.simpleName
@ -17,8 +21,15 @@ internal class RestoreStorageViewModel(
override val isRestoreOperation = true override val isRestoreOperation = true
override fun onLocationSet(uri: Uri) = Thread { override fun onLocationSet(uri: Uri) {
if (restorePlugin.hasBackup(uri)) { viewModelScope.launch(Dispatchers.IO) {
val hasBackup = try {
restorePlugin.hasBackup(uri)
} catch (e: IOException) {
Log.e(TAG, "Error reading URI: $uri", e)
false
}
if (hasBackup) {
saveStorage(uri) saveStorage(uri)
mLocationChecked.postEvent(LocationResult()) mLocationChecked.postEvent(LocationResult())
@ -29,6 +40,7 @@ internal class RestoreStorageViewModel(
val errorMsg = app.getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT) val errorMsg = app.getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT)
mLocationChecked.postEvent(LocationResult(errorMsg)) mLocationChecked.postEvent(LocationResult(errorMsg))
} }
}.start() }
}
} }

View file

@ -6,15 +6,22 @@ import android.view.View
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import kotlinx.android.synthetic.main.fragment_storage_check.*
private const val TITLE = "title" private const val TITLE = "title"
private const val ERROR_MSG = "errorMsg" private const val ERROR_MSG = "errorMsg"
class StorageCheckFragment : Fragment() { class StorageCheckFragment : Fragment() {
private lateinit var titleView: TextView
private lateinit var progressBar: ProgressBar
private lateinit var errorView: TextView
private lateinit var backButton: Button
companion object { companion object {
fun newInstance(title: String, errorMsg: String? = null): StorageCheckFragment { fun newInstance(title: String, errorMsg: String? = null): StorageCheckFragment {
val f = StorageCheckFragment() val f = StorageCheckFragment()
@ -28,7 +35,14 @@ class StorageCheckFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? { savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_storage_check, container, false) val v: View = inflater.inflate(R.layout.fragment_storage_check, container, false)
titleView = v.findViewById(R.id.titleView)
progressBar = v.findViewById(R.id.progressBar)
errorView = v.findViewById(R.id.errorView)
backButton = v.findViewById(R.id.backButton)
return v
} }
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onActivityCreated(savedInstanceState: Bundle?) {

View file

@ -173,9 +173,17 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
roots.add(root) roots.add(root)
} }
/**
* This adds a fake Nextcloud entry if no real one was found.
*
* If Nextcloud is *not* installed,
* the user will always have the option to install it by clicking the entry.
*
* If it *is* installed and this is restore, the user can set up a new account by clicking.
* If this isn't restore, the entry will be disabled,
* because we don't know if there's no account or an activated passcode.
*/
private fun checkOrAddNextCloudRoot(roots: ArrayList<StorageRoot>) { private fun checkOrAddNextCloudRoot(roots: ArrayList<StorageRoot>) {
if (!isRestore) return
for (root in roots) { for (root in roots) {
// return if we already have a NextCloud storage root // return if we already have a NextCloud storage root
if (root.authority == AUTHORITY_NEXTCLOUD) return if (root.authority == AUTHORITY_NEXTCLOUD) return
@ -188,16 +196,20 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
putExtra("onlyAdd", true) putExtra("onlyAdd", true)
} }
val isInstalled = packageManager.resolveActivity(intent, 0) != null val isInstalled = packageManager.resolveActivity(intent, 0) != null
val summaryRes = if (isInstalled) {
if (isRestore) R.string.storage_fake_nextcloud_summary_installed
else R.string.storage_fake_nextcloud_summary_unavailable
} else R.string.storage_fake_nextcloud_summary
val root = StorageRoot( val root = StorageRoot(
authority = AUTHORITY_NEXTCLOUD, authority = AUTHORITY_NEXTCLOUD,
rootId = "fake", rootId = "fake",
documentId = "fake", documentId = "fake",
icon = getIcon(context, AUTHORITY_NEXTCLOUD, "fake", 0), icon = getIcon(context, AUTHORITY_NEXTCLOUD, "fake", 0),
title = context.getString(R.string.storage_fake_nextcloud_title), title = context.getString(R.string.storage_fake_nextcloud_title),
summary = context.getString(if (isInstalled) R.string.storage_fake_nextcloud_summary_installed else R.string.storage_fake_nextcloud_summary), summary = context.getString(summaryRes),
availableBytes = null, availableBytes = null,
isUsb = false, isUsb = false,
enabled = true, enabled = !isInstalled || isRestore,
overrideClickListener = { overrideClickListener = {
if (isInstalled) context.startActivity(intent) if (isInstalled) context.startActivity(intent)
else { else {

View file

@ -10,13 +10,16 @@ import android.view.View
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity.RESULT_OK import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE
import com.stevesoltys.seedvault.ui.REQUEST_CODE_OPEN_DOCUMENT_TREE import com.stevesoltys.seedvault.ui.REQUEST_CODE_OPEN_DOCUMENT_TREE
import kotlinx.android.synthetic.main.fragment_storage_root.*
import org.koin.androidx.viewmodel.ext.android.getSharedViewModel import org.koin.androidx.viewmodel.ext.android.getSharedViewModel
internal class StorageRootsFragment : Fragment(), StorageRootClickedListener { internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
@ -32,12 +35,29 @@ internal class StorageRootsFragment : Fragment(), StorageRootClickedListener {
} }
private lateinit var viewModel: StorageViewModel private lateinit var viewModel: StorageViewModel
private lateinit var titleView: TextView
private lateinit var warningIcon: ImageView
private lateinit var warningText: TextView
private lateinit var divider: View
private lateinit var listView: RecyclerView
private lateinit var progressBar: ProgressBar
private lateinit var backView: TextView
private val adapter by lazy { StorageRootAdapter(viewModel.isRestoreOperation, this) } private val adapter by lazy { StorageRootAdapter(viewModel.isRestoreOperation, this) }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? { savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_storage_root, container, false) val v: View = inflater.inflate(R.layout.fragment_storage_root, container, false)
titleView = v.findViewById(R.id.titleView)
warningIcon = v.findViewById(R.id.warningIcon)
warningText = v.findViewById(R.id.warningText)
divider = v.findViewById(R.id.divider)
listView = v.findViewById(R.id.listView)
progressBar = v.findViewById(R.id.progressBar)
backView = v.findViewById(R.id.backView)
return v
} }
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onActivityCreated(savedInstanceState: Bundle?) {

View file

@ -1,10 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?android:attr/textColorSecondary" android:tint="?attr/colorControlNormal"
android:viewportWidth="24.0" android:viewportWidth="24.0"
android:viewportHeight="24.0"> android:viewportHeight="24.0">
<path <path
android:fillColor="#FF000000" android:fillColor="#FFFFFFFF"
android:pathData="M16,1L8,1C6.34,1 5,2.34 5,4v16c0,1.66 1.34,3 3,3h8c1.66,0 3,-1.34 3,-3L19,4c0,-1.66 -1.34,-3 -3,-3zM14,21h-4v-1h4v1zM17.25,18L6.75,18L6.75,4h10.5v14z" /> android:pathData="M16,1L8,1C6.34,1 5,2.34 5,4v16c0,1.66 1.34,3 3,3h8c1.66,0 3,-1.34 3,-3L19,4c0,-1.66 -1.34,-3 -3,-3zM14,21h-4v-1h4v1zM17.25,18L6.75,18L6.75,4h10.5v14z" />
</vector> </vector>

View file

@ -1,10 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?android:attr/textColorSecondary" android:tint="?attr/colorControlNormal"
android:viewportWidth="24.0" android:viewportWidth="24.0"
android:viewportHeight="24.0"> android:viewportHeight="24.0">
<path <path
android:fillColor="#FF000000" android:fillColor="#FFFFFFFF"
android:pathData="M15,7v4h1v2h-3V5h2l-3,-4 -3,4h2v8H8v-2.07c0.7,-0.37 1.2,-1.08 1.2,-1.93 0,-1.21 -0.99,-2.2 -2.2,-2.2 -1.21,0 -2.2,0.99 -2.2,2.2 0,0.85 0.5,1.56 1.2,1.93V13c0,1.11 0.89,2 2,2h3v3.05c-0.71,0.37 -1.2,1.1 -1.2,1.95 0,1.22 0.99,2.2 2.2,2.2 1.21,0 2.2,-0.98 2.2,-2.2 0,-0.85 -0.49,-1.58 -1.2,-1.95V15h3c1.11,0 2,-0.89 2,-2v-2h1V7h-4z" /> android:pathData="M15,7v4h1v2h-3V5h2l-3,-4 -3,4h2v8H8v-2.07c0.7,-0.37 1.2,-1.08 1.2,-1.93 0,-1.21 -0.99,-2.2 -2.2,-2.2 -1.21,0 -2.2,0.99 -2.2,2.2 0,0.85 0.5,1.56 1.2,1.93V13c0,1.11 0.89,2 2,2h3v3.05c-0.71,0.37 -1.2,1.1 -1.2,1.95 0,1.22 0.99,2.2 2.2,2.2 1.21,0 2.2,-0.98 2.2,-2.2 0,-0.85 -0.49,-1.58 -1.2,-1.95V15h3c1.11,0 2,-0.89 2,-2v-2h1V7h-4z" />
</vector> </vector>

View file

@ -62,6 +62,19 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/appNameView" /> app:layout_constraintTop_toBottomOf="@+id/appNameView" />
<TextView
android:id="@+id/versionView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/about_version"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/summaryView" />
<TextView <TextView
android:id="@+id/licenseView" android:id="@+id/licenseView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -73,7 +86,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0" app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/summaryView" /> app:layout_constraintTop_toBottomOf="@+id/versionView" />
<TextView <TextView
android:id="@+id/authorView" android:id="@+id/authorView"

View file

@ -19,11 +19,13 @@
<TextView <TextView
android:id="@+id/titleView" android:id="@+id/titleView"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="16dp" android:layout_margin="16dp"
android:text="@string/storage_fragment_backup_title" android:text="@string/storage_fragment_backup_title"
android:textSize="24sp" android:textSize="24sp"
android:gravity="center"
tools:text="Choose where to store backup (is a short title, but it can be longer)"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" /> app:layout_constraintTop_toBottomOf="@+id/imageView" />

View file

@ -13,7 +13,6 @@
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:tint="?android:attr/textColorSecondary"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
@ -26,7 +25,9 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:textColor="?android:attr/textColorPrimary" android:textColor="?android:attr/textColorPrimary"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toTopOf="@+id/summaryView" app:layout_constraintBottom_toTopOf="@+id/summaryView"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0" app:layout_constraintHorizontal_bias="0.0"
@ -40,9 +41,11 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:textColor="?android:attr/textColorTertiary" android:textColor="?android:attr/textColorTertiary"
android:textSize="12sp" android:textSize="12sp"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0" app:layout_constraintHorizontal_bias="0.0"

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="settings_auto_restore_title">استعادة تلقائية</string>
<string name="settings_backup_location_title">موقع النسخ الاحتياطي</string>
<string name="settings_backup_location_picker">اختر موقع النسخ الاحتياطي</string>
<string name="settings_backup_location">موقع النسخ الاحتياطي</string>
<string name="settings_backup">احتفظ باحتياطي من معلوماتي</string>
<string name="restore_backup_button">استرجاع النسخة الاحتياطية</string>
<string name="current_destination_string">حالة النسخ الاحتياطي والإعدادات</string>
<string name="backup">نسخ احتياطية</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="settings_backup_location_invalid">Odabrana lokacija se ne može da koristiti.</string>
<string name="settings_backup_location_title">Lokacija rezervne kopije</string>
<string name="settings_backup_location_picker">Odaberite lokaciju rezervne kopije</string>
<string name="settings_backup_location">Lokacija rezervne kopije</string>
<string name="settings_backup">Napravi rezervnu kopiju mojih podataka</string>
<string name="restore_backup_button">Vrati rezervnu kopiju</string>
<string name="current_destination_string">Status i podešavanja rezervne kopije</string>
<string name="data_management_label">Rezervna kopija Seedvault</string>
<string name="backup">Rezervna kopija</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="settings_backup_last_backup_never">Nie</string>
<string name="settings_backup_location_internal">Interner Speicher</string>
<string name="settings_backup_location_none">Keiner</string>
<string name="settings_backup_location">Sicherungsort</string>
<string name="settings_backup">Meine Daten sichern</string>
<string name="restore_backup_button">Sicherung wiederherstellen</string>
<string name="backup">Sicherung</string>
<string name="notification_restore_error_title">Daten von %1$s konnten nicht wiederhergestellt werden</string>
<string name="notification_restore_error_channel_title">Fehler bei automatischer Wiederherstellung vom Laufwerk</string>
<string name="notification_error_action">Beheben</string>
<string name="notification_error_text">Eine Gerätesicherung konnte nicht durchgeführt werden.</string>
<string name="notification_error_title">Sicherungsfehler</string>
<string name="notification_error_channel_title">Fehlermeldung</string>
<string name="notification_failed_title">Sicherung fehlgeschlagen</string>
<string name="notification_success_title">Sicherung fertiggestellt</string>
<string name="notification_title">Sicherung läuft</string>
<string name="notification_channel_title">Sicherungsbenachrichtigung</string>
<string name="recovery_code_error_checksum_word">Ihr Wiederherstellungsschlüssel ist ungültig. Bitte prüfen Sie alle eingegebenen Wörter und versuchen Sie es erneut!</string>
<string name="recovery_code_error_invalid_word">Falsches Wort. Meinten Sie %1$s oder %2$s\?</string>
<string name="recovery_code_error_empty_word">Sie vergaßen, dieses Wort einzugeben.</string>
<string name="recovery_code_input_hint_12">Wort 12</string>
<string name="recovery_code_input_hint_11">Wort 11</string>
<string name="recovery_code_input_hint_10">Wort 10</string>
<string name="recovery_code_input_hint_9">Wort 9</string>
<string name="recovery_code_input_hint_8">Wort 8</string>
<string name="recovery_code_input_hint_7">Wort 7</string>
<string name="recovery_code_input_hint_6">Wort 6</string>
<string name="recovery_code_input_hint_5">Wort 5</string>
<string name="recovery_code_input_hint_4">Wort 4</string>
<string name="recovery_code_input_hint_3">Wort 3</string>
<string name="recovery_code_input_hint_2">Wort 2</string>
<string name="recovery_code_input_hint_1">Wort 1</string>
<string name="recovery_code_done_button">Fertig</string>
<string name="recovery_code_input_intro">Geben Sie ihren aus 12 Wörtern bestehenden Wiederherstellungsschlüssel ein, den sie beim Konfigurieren der Sicherungen aufgeschrieben haben.</string>
<string name="recovery_code_confirm_intro">Geben sie Ihren aus 12 Wörtern bestehenden Wiederherstellungsschlüssel ein, um sicher zu gehen, dass er funktionieren wird, wenn sie ihn brauchen.</string>
<string name="storage_available_bytes"><xliff:g example="1 GB" id="size">%1$s</xliff:g> frei</string>
<string name="settings_backup_status_title">App-Sicherungsstatus</string>
<string name="settings_backup_apk_dialog_message">Beim Deaktivieren der App-Sicherungen werden immer noch die App-Daten gesichert. Allerdings werden diese nicht automatisch wiederhergestellt.
\n
\nSie werden Ihre Apps manuell installieren müssen während \"Automatische Sicherungen\" aktiviert sind.</string>
<string name="settings_backup_apk_summary">Die App\'s an sich sichern. Ohne würden nur die App-Daten gesichert werden.</string>
<string name="storage_check_fragment_restore_title">Suche nach Sicherungen…</string>
<string name="storage_check_fragment_backup_title">Bereite Speicherort vor…</string>
<string name="storage_fragment_backup_title">Wählen Sie aus, wo die Sicherungen gespeichert werden sollen</string>
<string name="recovery_code_confirm_button">Wiederherstellungsschlüssel bestätigen</string>
<string name="recovery_code_write_it_down">Schreiben Sie ihn jetzt auf!</string>
<string name="recovery_code_12_word_intro">Sie benötigen Ihren aus 12 Wörtern bestehenden Wiederherstellungsschlüssel zum Wiederherstellen der gesicherten Daten.</string>
<string name="recovery_code_title">Wiederherstellungsschlüssel</string>
<string name="storage_check_fragment_error_button">Zurück</string>
<string name="storage_check_fragment_permission_error">Es fehlen Schreib-Berechtigungen am Speicherort.</string>
<string name="storage_check_fragment_backup_error">Ein Fehler trat beim Zugriff auf den Speicherort auf.</string>
<string name="storage_fake_nextcloud_summary_installed">Klicken Sie hier, um ein Konto einzurichten</string>
<string name="storage_fake_nextcloud_summary">Jetzt Installieren</string>
<string name="storage_fake_drive_summary">muss angeschlossen sein</string>
<string name="storage_fake_drive_title">USB-Speichergerät</string>
<string name="storage_fragment_warning">Personen mit Zugriff auf Ihren Speicherort können herausfinden, welche Apps sie nutzen, allerdings nicht auf Ihre APP-Daten zugreifen.</string>
<string name="storage_fragment_restore_title">Wo befinden sich Ihre Sicherungen\?</string>
<string name="settings_backup_now">Jetzt sichern</string>
<string name="settings_backup_exclude_apps">Apps ausschließen</string>
<string name="settings_backup_status_summary">Letzte Sicherung: %1$s</string>
<string name="settings_backup_apk_dialog_disable">App-Sicherungen deaktivieren</string>
<string name="settings_backup_apk_dialog_cancel">Abbrechen</string>
<string name="settings_backup_apk_dialog_title">Wollen Sie die App-Sicherungen wirklich deaktivieren\?</string>
<string name="settings_info">Alle Backups auf ihrem Gerät sind verschlüsselt. Zum Wiederherstellen benötigen Sie Ihren aus 12 Wörtern bestehenden Wiederherstellungsschlüssel.</string>
<string name="settings_backup_apk_title">App-Sicherung</string>
<string name="settings_category_app_data_backup">App-Daten-Sicherung</string>
<string name="settings_auto_restore_summary_usb">Achtung: Ihr %1$s muss für diese Aktion verbunden sein.</string>
<string name="settings_auto_restore_summary">Bei der erneuten Installation einer APP die gesicherten Daten und Einstellungen wiederherstellen.</string>
<string name="settings_auto_restore_title">Automatische Wiederherstellung</string>
<string name="about_source_code">Quellcode: https://github.com/stevesoltys/seedvault</string>
<string name="about_sponsor">Gesponsert von: <a href="https://www.calyxinstitute.org">Calyx Institute</a> zur Verwendung in <a href="https://calyxos.org">CalyxOS</a></string>
<string name="about_design">Design von: <a href="https://www.glennsorrentino.com/">Glenn Sorrentino</a></string>
<string name="about_author">Geschrieben von: <a href="https://github.com/stevesoltys">Steve Soltys</a> und <a href="https://blog.grobox.de">Torsten Grote</a></string>
<string name="about_license">Lizenz: <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache2</a></string>
<string name="about_summary">Eine Sicherungsanwendung, die die interne Sicherungs-API von Android verwendet.</string>
<string name="about_title">Über</string>
<string name="storage_internal_warning_use_anyway">Trotzdem verwenden</string>
<string name="storage_internal_warning_choose_other">Anderen wählen</string>
<string name="storage_internal_warning_message">Sie haben den internen Speicher für Ihre Sicherung ausgewählt. Dieser ist nicht verfügbar, wenn Ihr Telefon verloren geht oder kaputt ist.</string>
<string name="storage_internal_warning_title">Warnung</string>
<string name="restore_finished_button">Beenden</string>
<string name="restore_finished_error">Beim Wiederherstellen der Sicherung ist ein Fehler aufgetreten.</string>
<string name="restore_finished_success">Wiederherstellung abgeschlossen</string>
<string name="restore_magic_package">System-Paketmanager</string>
<string name="notification_restore_error_text">Schließen Sie Ihr %1$s an, bevor Sie die App installieren, um die Daten aus der Sicherung wiederherzustellen.</string>
<string name="restore_app_quota_exceeded">Sicherungskontingent überschritten</string>
<string name="restore_app_not_installed">App ist nicht installiert</string>
<string name="restore_app_not_allowed">App erlaubt keine Sicherung</string>
<string name="restore_app_no_data">App hat keine Daten für die Sicherung gemeldet</string>
<string name="restore_app_was_stopped">Nicht gesichert, da es in letzter Zeit nicht verwendet wurde</string>
<string name="restore_app_not_yet_backed_up">Noch nicht gesichert</string>
<string name="restore_restoring">Sicherung wird wiederhergestellt</string>
<string name="restore_installing_packages">Apps neu installieren</string>
<string name="restore_set_empty_result">Am angegebenen Speicherort wurden keine geeigneten Sicherungen gefunden.
\n
\nDies ist höchstwahrscheinlich auf einen falschen Wiederherstellungscode oder einen Speicherfehler zurückzuführen.</string>
<string name="restore_next">Weiter</string>
<string name="restore_title">Wiederherstellung von einer Sicherung</string>
<string name="restore_set_error">Beim Laden der Sicherungen ist ein Fehler aufgetreten.</string>
<string name="restore_invalid_location_message">Wir konnten keine Sicherungen an diesem Speicherort finden.
\n
\nWählen Sie einen anderen Speicherort aus, der einen %s Ordner enthält.</string>
<string name="restore_invalid_location_title">Keine Sicherungen gefunden</string>
<string name="restore_back">Nicht wiederherstellen</string>
<string name="restore_restore_set_times">Letzte Sicherung %1$s · Erste %2$s.</string>
<string name="restore_choose_restore_set">Wählen Sie eine Sicherung aus, um sie wiederherzustellen</string>
<string name="notification_restore_error_action">App deinstallieren</string>
<string name="notification_success_text">%1$d von %2$d Apps gesichert. Tippen Sie, um mehr zu erfahren.</string>
<string name="notification_backup_already_running">Sicherung wird bereits durchgeführt</string>
<string name="notification_backup_result_error">Sicherung fehlgeschlagen</string>
<string name="notification_backup_result_rejected">Nicht gesichert</string>
<string name="notification_backup_result_complete">Sicherung abgeschlossen</string>
<string name="storage_fake_nextcloud_summary_unavailable">Konto nicht verfügbar. Richten Sie ein Konto ein (oder deaktivieren Sie das Passcode).</string>
<string name="settings_backup_location_invalid">Der gewählte Speicherort kann nicht verwendet werden.</string>
<string name="settings_backup_location_picker">Sicherungsort wählen</string>
<string name="settings_backup_location_title">Sicherungsort</string>
<string name="current_destination_string">Sicherungsstatus und Einstellungen</string>
<string name="data_management_label">Seedvault Sicherung</string>
</resources>

View file

@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="storage_check_fragment_error_button">Πίσω</string>
<string name="storage_check_fragment_permission_error">Δεν είναι δυνατή η λήψη δικαιωμάτων εγγραφής στην τοποθεσία αντιγράφων ασφαλείας.</string>
<string name="storage_check_fragment_backup_error">Παρουσιάστηκε σφάλμα κατά την πρόσβαση στην τοποθεσία αντιγράφων ασφαλείας.</string>
<string name="storage_check_fragment_restore_title">Αναζήτηση αντιγράφων ασφαλείας…</string>
<string name="storage_check_fragment_backup_title">Προετοιμασία τοποθεσίας αντιγράφων ασφαλείας…</string>
<string name="storage_available_bytes"><xliff:g example="1 GB" id="size">%1$s</xliff:g> ελεύθερα</string>
<string name="storage_fake_drive_summary">Πρέπει να είναι συνδεδεμένο</string>
<string name="storage_fragment_warning">Άτομα με πρόσβαση στον αποθηκευτικό χώρο σας μπορούν να μάθουν ποιες εφαρμογές χρησιμοποιείτε, αλλά δεν έχουν πρόσβαση στα δεδομένα των εφαρμογών.</string>
<string name="storage_fragment_restore_title">Πού βρίσκονται τα αντίγραφα ασφαλείας σας;</string>
<string name="storage_fragment_backup_title">Επιλέξτε πού θα αποθηκεύονται τα αντίγραφα ασφαλείας</string>
<string name="settings_backup_now">Δημιουργία αντιγράφων ασφαλείας τώρα</string>
<string name="settings_backup_exclude_apps">Εξαίρεση εφαρμογών</string>
<string name="settings_backup_status_title">Κατάσταση αντιγράφων ασφαλείας εφαρμογών</string>
<string name="settings_backup_apk_dialog_disable">Απενεργοποίηση αντιγράφων ασφαλείας εφαρμογών</string>
<string name="settings_backup_apk_dialog_cancel">Άκυρο</string>
<string name="settings_backup_apk_dialog_message">Η απενεργοποιημένη δημιουργία αντιγράφων ασφαλείας θα εξακολουθεί να δημιουργεί αντίγραφα ασφαλείας των δεδομένων εφαρμογών. Ωστόσο, δεν θα επαναφέρονται αυτόματα.
\n
\nΘα πρέπει να εγκαταστήσετε όλες τις εφαρμογές σας χειροκίνητα ενώ έχετε ενεργοποιήσει την \"Αυτόματη επαναφορά\".</string>
<string name="settings_backup_apk_dialog_title">Θέλετε πραγματικά να απενεργοποιήσετε τα αντίγραφα ασφαλείας εφαρμογών;</string>
<string name="settings_backup_apk_summary">Δημιουργία αντιγράφων των ίδιων των εφαρμογών. Διαφορετικά, θα δημιουργούνται αντίγραφα ασφαλείας μόνο δεδομένων εφαρμογών.</string>
<string name="settings_backup_apk_title">Αντίγραφα ασφαλείας εφαρμογών</string>
<string name="settings_category_app_data_backup">Αντίγραφα ασφαλείας δεδομένων εφαρμογών</string>
<string name="settings_auto_restore_summary_usb">Σημείωση: Το %1$s πρέπει να συνδεθεί για να λειτουργήσει η αυτόματη επαναφορά.</string>
<string name="settings_auto_restore_summary">Κατά την επανεγκατάσταση μιας εφαρμογής, επαναφέρετε τα αντίγραφα ασφαλείας των ρυθμίσεων και των δεδομένων.</string>
<string name="settings_auto_restore_title">Αυτόματη επαναφορά</string>
<string name="settings_backup_last_backup_never">Ποτέ</string>
<string name="settings_backup_location_none">Καμία</string>
<string name="settings_backup_location">Τοποθεσία αντιγράφων ασφαλείας</string>
<string name="settings_backup">Δημιουργία αντιγράφου ασφαλείας των δεδομένων μου</string>
<string name="restore_backup_button">Επαναφορά αντιγράφου ασφαλείας</string>
<string name="backup">Αντίγραφο ασφαλείας</string>
<string name="settings_info">Όλα τα αντίγραφα ασφαλείας είναι κρυπτογραφημένα στο τηλέφωνό σας. Για επαναφορά από το αντίγραφο ασφαλείας θα χρειαστείτε τον κωδικό αποκατάστασης 12 λέξεων.</string>
<string name="about_source_code">Πηγαίος κώδικας: https://github.com/stevesoltys/seedvault</string>
<string name="about_sponsor">Χορηγός: <a href="https://www.calyxinstitute.org">Calyx Institute</a> για χρήση στο <a href="https://calyxos.org">CalyxOS</a></string>
<string name="about_design">Σχεδιασμός από: <a href="https://www.glennsorrentino.com/">Glenn Sorrentino</a></string>
<string name="about_author">Αναπτύχθηκε από: <a href="https://github.com/stevesoltys">Steve Soltys</a> και <a href="https://blog.grobox.de">Torsten Grote</a></string>
<string name="about_license">Άδεια: <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache2</a></string>
<string name="about_summary">Μια εφαρμογή δημιουργίας αντιγράφων ασφαλείας που χρησιμοποιεί το εσωτερικό API αντιγράφων ασφαλείας του Android.</string>
<string name="about_title">Σχετικά με</string>
<string name="storage_internal_warning_use_anyway">Χρησιμοποιήστε ούτως ή άλλως</string>
<string name="storage_internal_warning_choose_other">Επιλέξτε άλλο</string>
<string name="storage_internal_warning_message">Έχετε επιλέξει εσωτερικό αποθηκευτικό χώρο για το αντίγραφο ασφαλείας σας. Αυτό δεν θα είναι διαθέσιμο όταν το τηλέφωνό σας χαθεί ή σπάσει.</string>
<string name="storage_internal_warning_title">Προσοχή</string>
<string name="restore_finished_button">Τέλος</string>
<string name="restore_finished_error">Παρουσιάστηκε σφάλμα κατά την επαναφορά του αντιγράφου ασφαλείας.</string>
<string name="restore_finished_success">Η επαναφορά ολοκληρώθηκε</string>
<string name="restore_app_quota_exceeded">Έγινε υπέρβαση του ορίου αντιγράφων ασφαλείας</string>
<string name="restore_app_not_installed">Η εφαρμογή δεν έχει εγκατασταθεί</string>
<string name="restore_app_not_allowed">Η εφαρμογή δεν επιτρέπει τη δημιουργία αντιγράφων ασφαλείας</string>
<string name="restore_app_no_data">Η εφαρμογή δεν ανέφερε δεδομένα για δημιουργία αντιγράφων ασφαλείας</string>
<string name="restore_magic_package">Διαχειριστής πακέτων συστήματος</string>
<string name="restore_restoring">Επαναφορά αντιγράφου ασφαλείας</string>
<string name="restore_next">Επόμενο</string>
<string name="restore_installing_packages">Επανεγκατάσταση εφαρμογών</string>
<string name="restore_set_empty_result">Δεν βρέθηκαν κατάλληλα αντίγραφα ασφαλείας στη δεδομένη τοποθεσία.
\n
\nΑυτό πιθανότατα οφείλεται σε λάθος κωδικό ανάκτησης ή σε σφάλμα αποθηκευτικού χώρου.</string>
<string name="restore_set_error">Παρουσιάστηκε σφάλμα κατά τη φόρτωση των αντιγράφων ασφαλείας.</string>
<string name="restore_invalid_location_message">Δεν ήταν δυνατή η εύρεση αντιγράφων ασφαλείας σε αυτήν την τοποθεσία.
\n
\nΕπιλέξτε άλλη τοποθεσία που περιέχει ένα φάκελο %s.</string>
<string name="restore_invalid_location_title">Δεν βρέθηκαν αντίγραφα ασφαλείας</string>
<string name="restore_back">Να μην γίνει επαναφορά</string>
<string name="restore_restore_set_times">Τελευταίο αντίγραφο ασφαλείας %1$s · Πρώτο %2$s.</string>
<string name="restore_choose_restore_set">Επιλέξτε ένα αντίγραφο ασφαλείας για επαναφορά</string>
<string name="restore_title">Επαναφορά από αντίγραφο ασφαλείας</string>
<string name="notification_restore_error_action">Απεγκατάσταση εφαρμογής</string>
<string name="notification_restore_error_text">Συνδέστε το %1$s πριν εγκαταστήσετε την εφαρμογή για να επαναφέρετε τα δεδομένα της από το αντίγραφο ασφαλείας.</string>
<string name="notification_restore_error_title">Δεν ήταν δυνατή η επαναφορά δεδομένων για %1$s</string>
<string name="notification_restore_error_channel_title">Σφάλμα αυτόματης επαναφοράς μονάδας flash</string>
<string name="notification_error_action">Επιδιόρθωση</string>
<string name="notification_error_text">Απέτυχε η εκτέλεση ενός αντιγράφου ασφαλείας συσκευής.</string>
<string name="notification_error_title">Σφάλμα δημιουργίας αντιγράφων ασφαλείας</string>
<string name="notification_error_channel_title">Ειδοποίηση σφάλματος</string>
<string name="notification_failed_title">Η δημιουργία αντιγράφων ασφαλείας απέτυχε</string>
<string name="notification_success_text">Δημιουργήθηκαν αντίγραφα ασφαλείας για %1$d από %2$d εφαρμογές. Πατήστε για να μάθετε περισσότερα.</string>
<string name="notification_success_title">Η δημιουργία αντιγράφων ασφαλείας ολοκληρώθηκε</string>
<string name="notification_backup_already_running">Η δημιουργία αντιγράφων ασφαλείας βρίσκεται ήδη σε εξέλιξη</string>
<string name="notification_title">Εκτελείται δημιουργία αντιγράφων ασφαλείας</string>
<string name="notification_channel_title">Ειδοποίηση δημιουργίας αντιγράφων ασφαλείας</string>
<string name="recovery_code_error_checksum_word">Ο κωδικός σας δεν είναι έγκυρος. Ελέγξτε όλες τις λέξεις και δοκιμάστε ξανά!</string>
<string name="recovery_code_error_invalid_word">Λάθος λέξη. Εννοείτε %1$s ή %2$s;</string>
<string name="recovery_code_error_empty_word">Ξεχάσατε να εισαγάγετε αυτήν τη λέξη.</string>
<string name="recovery_code_input_hint_12">Λέξη 12</string>
<string name="recovery_code_input_hint_11">Λέξη 11</string>
<string name="recovery_code_input_hint_10">Λέξη 10</string>
<string name="recovery_code_input_hint_9">Λέξη 9</string>
<string name="recovery_code_input_hint_8">Λέξη 8</string>
<string name="recovery_code_input_hint_7">Λέξη 7</string>
<string name="recovery_code_input_hint_6">Λέξη 6</string>
<string name="recovery_code_input_hint_5">Λέξη 5</string>
<string name="recovery_code_input_hint_4">Λέξη 4</string>
<string name="recovery_code_input_hint_3">Λέξη 3</string>
<string name="recovery_code_input_hint_2">Λέξη 2</string>
<string name="recovery_code_input_hint_1">Λέξη 1</string>
<string name="recovery_code_done_button">Ολοκληρώθηκε</string>
<string name="recovery_code_input_intro">Εισαγάγετε τον κωδικό ανάκτησης 12 λέξεων που γράψατε κατά τη δημιουργία αντιγράφων ασφαλείας.</string>
<string name="recovery_code_confirm_intro">Εισαγάγετε τον κωδικό ανάκτησης 12 λέξεων για να βεβαιωθείτε ότι θα λειτουργήσει όταν το χρειάζεστε.</string>
<string name="recovery_code_confirm_button">Επιβεβαίωση κωδικού</string>
<string name="recovery_code_write_it_down">Γράψτε το σε χαρτί τώρα!</string>
<string name="recovery_code_12_word_intro">Χρειάζεστε τον κωδικό ανάκτησης 12 λέξεων για να επαναφέρετε τα αντίγραφα ασφαλείας των δεδομένων.</string>
<string name="recovery_code_title">Κωδικός ανάκτησης</string>
<string name="storage_fake_nextcloud_summary_installed">Κάντε κλικ για να δημιουργήσετε λογαριασμό</string>
<string name="storage_fake_nextcloud_summary">Κάντε κλικ για εγκατάσταση</string>
<string name="storage_fake_drive_title">Μονάδα USB flash</string>
<string name="settings_backup_status_summary">Τελευταίο αντίγραφο ασφαλείας: %1$s</string>
<string name="settings_backup_location_internal">Εσωτερικός αποθηκευτικός χώρος</string>
<string name="notification_backup_result_error">Η δημιουργία αντιγράφων ασφαλείας απέτυχε</string>
<string name="notification_backup_result_rejected">Δεν δημιουργήθηκε αντίγραφο ασφαλείας</string>
<string name="notification_backup_result_complete">Η δημιουργία αντιγράφων ασφαλείας ολοκληρώθηκε</string>
<string name="settings_backup_location_invalid">Η επιλεγμένη τοποθεσία δεν μπορεί να χρησιμοποιηθεί.</string>
<string name="settings_backup_location_title">Τοποθεσία αντιγράφων ασφαλείας</string>
<string name="settings_backup_location_picker">Επιλέξτε την τοποθεσία των αντιγράφων ασφαλείας</string>
<string name="restore_app_was_stopped">Δεν δημιουργήθηκε αντίγραφο ασφαλείας καθώς δεν χρησιμοποιήθηκε πρόσφατα</string>
<string name="restore_app_not_yet_backed_up">Δεν έχει δημιουργηθεί ακόμη αντίγραφο ασφαλείας</string>
<string name="storage_fake_nextcloud_summary_unavailable">Ο λογαριασμός δεν είναι διαθέσιμος. Ρυθμίστε έναν (ή απενεργοποιήστε τον κωδικό πρόσβασης).</string>
<string name="current_destination_string">Κατάσταση αντιγράφων ασφαλείας και ρυθμίσεις</string>
<string name="data_management_label">Αντίγραφα ασφαλείας Seedvault</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="about_source_code">Código fuente: https://github.com/stevesoltys/seedvault</string>
<string name="about_sponsor">Patrocinado por: <a href="https://www.calyxinstitute.org">Calyx Institute</a> for use in <a href="https://calyxos.org">CalyxOS</a></string>
<string name="about_design">Diseño por: <a href="https://www.glennsorrentino.com/">Glenn Sorrentino</a></string>
<string name="about_author">Escrito por: <a href="https://github.com/stevesoltys">Steve Soltys</a> and <a href="https://blog.grobox.de">Torsten Grote</a></string>
<string name="about_license">Licencia: <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache2</a></string>
<string name="about_summary">Una aplicación de respaldo que usa la API interna de respaldo de Android.</string>
<string name="about_title">Acerca de</string>
<string name="storage_internal_warning_use_anyway">Usar de todos modos</string>
<string name="storage_internal_warning_choose_other">Elegir Otro</string>
<string name="storage_internal_warning_message">Has elegido el almacenamiento interno para su respaldo. Esto no estará disponible cuando su teléfono se pierda o se rompa.</string>
<string name="storage_internal_warning_title">Advertencia</string>
<string name="restore_finished_button">Terminar</string>
<string name="restore_finished_error">Se produjo un error al restaurar el respaldo.</string>
<string name="restore_finished_success">Restauración completa</string>
<string name="restore_app_quota_exceeded">Cuota de respaldo excedida</string>
<string name="restore_app_not_installed">Aplicación no instalada</string>
<string name="restore_app_not_allowed">La aplicación no permite respaldo</string>
<string name="restore_app_no_data">La aplicación no reportó datos para respaldo</string>
<string name="restore_magic_package">Administrador de Paquetes del Sistema</string>
<string name="restore_restoring">Restaurando Respaldo</string>
<string name="restore_next">Próximo</string>
<string name="restore_installing_packages">Reinstalar Aplicaciones</string>
<string name="restore_set_empty_result">No se encontraron respaldos en la ubicación dada.
\n
\nEsto probablemente se deba a un código de recuperación incorrecto o un error de almacenamiento.</string>
<string name="restore_set_error">Se produjo un error al cargar los respaldos.</string>
<string name="restore_invalid_location_message">No pudimos encontrar ningún respaldo en esta ubicación.
\n
\nElija otra ubicación que contenga una carpeta %s.</string>
<string name="restore_invalid_location_title">No se encontraron respaldos</string>
<string name="restore_back">No restaurar</string>
<string name="restore_restore_set_times">Último Respaldo %1$s · Primero %2$s.</string>
<string name="restore_choose_restore_set">Elije un respaldo para restaurar</string>
<string name="restore_title">Restaurar desde el respaldo</string>
<string name="notification_restore_error_action">Desinstalar Aplicación</string>
<string name="notification_restore_error_text">Enchufe su %1$s antes de instalar la aplicación para restaurar sus datos del respaldo.</string>
<string name="notification_restore_error_title">No se pudieron restaurar los datos para %1$s</string>
<string name="notification_restore_error_channel_title">Error de restauración automática de unidad flash</string>
<string name="notification_error_action">Reparar</string>
<string name="notification_error_text">No se pudo ejecutar un respaldo del dispositivo.</string>
<string name="notification_error_title">Error de Respaldo</string>
<string name="notification_error_channel_title">Notificación de Error</string>
<string name="notification_failed_title">Respaldo falló</string>
<string name="notification_success_title">Respaldo terminó</string>
<string name="notification_title">Ejecución de respaldo</string>
<string name="recovery_code_error_checksum_word">Tu código no es válido. Por favor revise todas las palabras e intente nuevamente!</string>
<string name="recovery_code_error_invalid_word">Palabra equivocada. ¿Quisiste decir %1$s o %2$s\?</string>
<string name="recovery_code_error_empty_word">Olvidaste ingresar esta palabra.</string>
<string name="recovery_code_input_hint_12">Palabra 12</string>
<string name="recovery_code_input_hint_11">Palabra 11</string>
<string name="recovery_code_input_hint_10">Palabra 10</string>
<string name="recovery_code_input_hint_9">Palabra 9</string>
<string name="recovery_code_input_hint_8">Palabra 8</string>
<string name="recovery_code_input_hint_7">Palabra 7</string>
<string name="recovery_code_input_hint_6">Palabra 6</string>
<string name="recovery_code_input_hint_5">Palabra 5</string>
<string name="recovery_code_input_hint_4">Palabra 4</string>
<string name="recovery_code_input_hint_3">Palabra 3</string>
<string name="recovery_code_input_hint_2">Palabra 2</string>
<string name="recovery_code_input_hint_1">Palabra 1</string>
<string name="recovery_code_confirm_intro">Ingresa tu código de recuperación de 12 palabras para asegurarte de que funcionará cuando lo necesite.</string>
<string name="recovery_code_done_button">Terminado</string>
<string name="recovery_code_input_intro">Ingresa tu código de recuperación de 12 palabras que escribiste al configurar los respaldos.</string>
<string name="recovery_code_confirm_button">Confirma Código</string>
<string name="recovery_code_write_it_down">¡Escríbelo en papel ahora!</string>
<string name="recovery_code_12_word_intro">Necesita tu código de recuperación de 12 palabras para restaurar los datos respaldados.</string>
<string name="recovery_code_title">Código de Recuperación</string>
<string name="storage_check_fragment_error_button">Atrás</string>
<string name="storage_check_fragment_permission_error">No se pudo obtener el permiso para escribir en la ubicación del respaldo.</string>
<string name="storage_check_fragment_backup_error">Se produjo un error al acceder a la ubicación del respaldo.</string>
<string name="storage_check_fragment_restore_title">Buscando respaldos…</string>
<string name="storage_check_fragment_backup_title">Inicializando la ubicación del respaldo…</string>
<string name="storage_fake_nextcloud_summary_installed">Haz clic para configurar la cuenta</string>
<string name="storage_fake_nextcloud_summary">Haz clic para instalar</string>
<string name="storage_fake_drive_summary">Necesita ser enchufado</string>
<string name="storage_fake_drive_title">Memoria USB</string>
<string name="storage_fragment_warning">Las personas con acceso a su ubicación de almacenamiento pueden saber qué aplicaciones usas, pero no reciben acceso a los datos de las aplicaciones.</string>
<string name="storage_fragment_restore_title">¿Dónde encontrar sus respaldos\?</string>
<string name="storage_fragment_backup_title">Elija dónde almacenar los respaldos</string>
<string name="settings_backup_now">Respaldar ahora</string>
<string name="settings_backup_exclude_apps">Excluir aplicaciones</string>
<string name="settings_backup_status_summary">Último respaldo: %1$s</string>
<string name="settings_backup_status_title">Estado de respaldo de la aplicación</string>
<string name="settings_backup_apk_dialog_disable">Deshabilitar respaldo de la aplicación</string>
<string name="settings_backup_apk_dialog_cancel">Cancelar</string>
<string name="settings_backup_apk_dialog_message">Respaldo deshabilitado de la aplicación aún seguirá haciendo un respaldo de los datos de la aplicación. Sin embargo, no se restaurará automáticamente.
\n
\nDeberás instalar todas tus aplicaciones manualmente mientras tengas activada la \"Restauración Automática\".</string>
<string name="settings_backup_apk_dialog_title">¿Deshabilitar realmente el respaldo de la aplicación\?</string>
<string name="settings_backup_apk_summary">Respalde las aplicaciones en sí. De lo contrario, solo se harían respaldos de los datos de la aplicación.</string>
<string name="settings_backup_apk_title">Respaldo de la aplicación</string>
<string name="settings_category_app_data_backup">Respaldo de datos de la aplicación</string>
<string name="settings_auto_restore_summary_usb">Nota: Su %1$s necesita estar enchufado para que esto funcione.</string>
<string name="settings_auto_restore_summary">Al reinstalar una aplicación, restaure la configuración y los datos respaldados.</string>
<string name="settings_auto_restore_title">Restauración automática</string>
<string name="settings_info">Todas las copias de seguridad están encriptadas en su teléfono. Para restaurar desde una copia de seguridad, necesitará su código de recuperación de 12 palabras.</string>
<string name="settings_backup_last_backup_never">Nunca</string>
<string name="settings_backup_location_internal">Almacenamiento Interno</string>
<string name="settings_backup_location_none">Ninguno</string>
<string name="settings_backup_location">Ubicación de respaldo</string>
<string name="restore_backup_button">Restaurar respaldo</string>
<string name="settings_backup">Respalda mi información</string>
<string name="backup">Respaldo</string>
</resources>

View file

@ -0,0 +1,121 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="storage_fragment_warning">Quienes tengan acceso a su ubicación de almacenamiento pueden ver qué aplicaciones utiliza, pero no podrán acceder a los datos de estas.</string>
<string name="about_source_code">Código fuente: https://github.com/stevesoltys/seedvault</string>
<string name="about_title">Acerca de</string>
<string name="storage_internal_warning_use_anyway">Usar de todos modos</string>
<string name="storage_internal_warning_title">Atención</string>
<string name="restore_finished_button">Finalizar</string>
<string name="restore_next">Siguiente</string>
<string name="restore_back">No restaurar</string>
<string name="notification_restore_error_action">Desinstalar aplicación</string>
<string name="notification_error_channel_title">Notificación de error</string>
<string name="recovery_code_error_empty_word">Olvidó escribir esta palabra.</string>
<string name="recovery_code_input_hint_12">Palabra 12</string>
<string name="recovery_code_input_hint_11">Palabra 11</string>
<string name="recovery_code_input_hint_10">Palabra 10</string>
<string name="recovery_code_input_hint_9">Palabra 9</string>
<string name="recovery_code_input_hint_8">Palabra 8</string>
<string name="recovery_code_input_hint_7">Palabra 7</string>
<string name="recovery_code_input_hint_6">Palabra 6</string>
<string name="recovery_code_input_hint_5">Palabra 5</string>
<string name="recovery_code_input_hint_4">Palabra 4</string>
<string name="recovery_code_input_hint_3">Palabra 3</string>
<string name="recovery_code_input_hint_2">Palabra 2</string>
<string name="recovery_code_input_hint_1">Palabra 1</string>
<string name="recovery_code_done_button">Hecho</string>
<string name="recovery_code_title">Código de recuperación</string>
<string name="storage_check_fragment_error_button">Atrás</string>
<string name="storage_fake_nextcloud_summary_installed">Pulse para configurar una cuenta</string>
<string name="storage_fake_nextcloud_summary">Pulse para instalar</string>
<string name="settings_backup_apk_dialog_cancel">Cancelar</string>
<string name="settings_backup_location_none">Ninguna</string>
<string name="settings_auto_restore_title">Restauración automática</string>
<string name="settings_auto_restore_summary_usb">Nota: su %1$s debe enchufarse para que esto funcione.</string>
<string name="settings_backup_last_backup_never">Nunca</string>
<string name="settings_backup_location_internal">Almacenamiento interno</string>
<string name="notification_restore_error_text">Conecta tus %1$s antes de instalar la aplicación para restaurar sus datos desde la copia de seguridad.</string>
<string name="notification_error_action">Arreglar</string>
<string name="about_sponsor">Patrocinado por: <a href="https://www.calyxinstitute.org"> Calyx Institute </a> para su uso en <a href="https://calyxos.org"> CalyxOS </a></string>
<string name="about_design">Diseño de: <a href="https://www.glennsorrentino.com/"> Glenn Sorrentino </a></string>
<string name="about_author">Escrito por: <a href="https://github.com/stevesoltys"> Steve Soltys </a> y <a href="https://blog.grobox.de"> Torsten Grote </a></string>
<string name="about_license">Licencia: <a href="https://www.apache.org/licenses/LICENSE-2.0"> Apache2 </a></string>
<string name="about_summary">Una aplicación de respaldo que utiliza la API de respaldo interno de Android.</string>
<string name="storage_internal_warning_choose_other">Escoger otro</string>
<string name="storage_internal_warning_message">Ha elegido almacenamiento interno para su copia de seguridad. Esto no estará disponible cuando su teléfono se pierda o se rompa.</string>
<string name="restore_finished_error">Ocurrió un error al restaurar la copia de seguridad.</string>
<string name="restore_finished_success">Restauración completada</string>
<string name="restore_app_quota_exceeded">Se ha superado la cuota de la copia de seguridad</string>
<string name="restore_app_not_installed">La aplicación no está instalada</string>
<string name="restore_app_not_allowed">La aplicación no permite copias de seguridad</string>
<string name="restore_app_no_data">La aplicación no reportó datos para la copia de seguridad</string>
<string name="restore_magic_package">Administrador de paquetes del sistema</string>
<string name="restore_restoring">Restaurando copia de seguridad</string>
<string name="restore_installing_packages">Re-Instalando aplicaciones</string>
<string name="restore_set_empty_result">No se han encontrado copias de seguridad adecuadas en la ubicación determinada.
\n
\nEsto es más probable debido a un código de recuperación incorrecto o un error de almacenamiento.</string>
<string name="restore_set_error">Se produjo un error al cargar las copias de seguridad.</string>
<string name="restore_invalid_location_message">No pudimos encontrar ninguna copia de seguridad en esta ubicación.
\n
\nPor favor, elija otra ubicación que contenga una carpeta %s.</string>
<string name="restore_invalid_location_title">No se encontraron copias de seguridad</string>
<string name="restore_restore_set_times">Última copia de seguridad %1$s · Primera %2$s.</string>
<string name="restore_choose_restore_set">Elija una copia de seguridad para restaurar</string>
<string name="restore_title">Restaurar desde copia de seguridad</string>
<string name="notification_restore_error_title">No se pudieron restaurar los datos por %1$s</string>
<string name="notification_restore_error_channel_title">Error en la restauración automática de la unidad flash</string>
<string name="notification_error_text">No se pudo ejecutar una copia de seguridad del dispositivo.</string>
<string name="notification_error_title">Error de copia de seguridad</string>
<string name="notification_failed_title">La copia de seguridad falló</string>
<string name="notification_success_title">Copia de seguridad terminada</string>
<string name="notification_title">Copia de seguridad en ejecución</string>
<string name="notification_channel_title">Notificación de copia de seguridad</string>
<string name="recovery_code_error_checksum_word">Su código es inválido. Por favor, compruebe todas las palabras e inténtelo de nuevo!</string>
<string name="recovery_code_error_invalid_word">Palabra equivocada. ¿Quisiste decir %1$s o %2$s \?</string>
<string name="recovery_code_input_intro">Ingrese su código de recuperación de 12 palabras que anotó al configurar las copias de seguridad.</string>
<string name="recovery_code_confirm_intro">Ingrese su código de recuperación de 12 palabras para asegurarse de que funcionará cuando lo necesite.</string>
<string name="recovery_code_confirm_button">Confirma código</string>
<string name="recovery_code_write_it_down">¡Escríbalo en papel ahora!</string>
<string name="recovery_code_12_word_intro">Necesita su código de recuperación de 12 palabras para restaurar los datos respaldados.</string>
<string name="storage_check_fragment_permission_error">No se pudo obtener el permiso para escribir en la ubicación de la copia de seguridad.</string>
<string name="storage_check_fragment_backup_error">Se produjo un error al acceder a la ubicación de la copia de seguridad.</string>
<string name="storage_check_fragment_restore_title">Buscando copias de seguridad…</string>
<string name="storage_check_fragment_backup_title">Iniciando la ubicación de la copia de seguridad…</string>
<string name="storage_available_bytes"><xliff:g example="1 GB" id="size">%1$s</xliff:g> libre</string>
<string name="storage_fake_drive_summary">Necesita estar enchufado</string>
<string name="storage_fake_drive_title">Unidad flash USB</string>
<string name="storage_fragment_restore_title">¿Dónde encontrar tus copias de seguridad\?</string>
<string name="storage_fragment_backup_title">Elija dónde almacenar las copias de seguridad</string>
<string name="settings_backup_now">Hacer copia de seguridad ahora</string>
<string name="settings_backup_exclude_apps">Excluir aplicaciones</string>
<string name="settings_backup_status_summary">Última copia de seguridad: %1$s</string>
<string name="settings_backup_status_title">Estado de la copia de seguridad de la aplicación</string>
<string name="settings_backup_apk_dialog_disable">Deshabilitar la copia de seguridad de la aplicación</string>
<string name="settings_backup_apk_dialog_message">La copia de seguridad de la aplicación aún desactivada seguirá respaldando los datos de la aplicación. Sin embargo, no se restaurará automáticamente.
\n
\nDeberá instalar todas sus aplicaciones manualmente mientras tiene activada la \"Restauración automática\".</string>
<string name="settings_backup_apk_dialog_title">¿Realmente desactivar la copia de seguridad de la aplicación\?</string>
<string name="settings_backup_apk_summary">Realice una copia de seguridad de las aplicaciones. De lo contrario, solo se realizarían copias de seguridad de los datos de la aplicación.</string>
<string name="settings_backup_apk_title">Copia de seguridad de la aplicación</string>
<string name="settings_category_app_data_backup">Copia de seguridad de datos de la aplicación</string>
<string name="settings_auto_restore_summary">Al reinstalar una aplicación, restaure la configuración y los datos respaldados.</string>
<string name="settings_info">Todas las copias de seguridad están encriptadas en su teléfono. Para restaurar desde la copia de seguridad, necesitará su código de recuperación de 12 palabras.</string>
<string name="settings_backup_location">Ubicación de la copia de seguridad</string>
<string name="settings_backup">Respaldar mi información</string>
<string name="restore_backup_button">Restaurar la copia de seguridad</string>
<string name="backup">Copia de seguridad</string>
<string name="notification_success_text">%1$d de %2$d aplicaciones respaldadas. Toque para obtener más información.</string>
<string name="notification_backup_already_running">Copia de seguridad en progreso</string>
<string name="restore_app_not_yet_backed_up">Sin respaldar aún</string>
<string name="restore_app_was_stopped">No está respaldado, ya que no se ha usado recientemente</string>
<string name="notification_backup_result_error">Falló la copia de seguridad</string>
<string name="notification_backup_result_rejected">No hay copia de seguridad</string>
<string name="notification_backup_result_complete">Copia de seguridad completa</string>
<string name="storage_fake_nextcloud_summary_unavailable">Cuenta no disponible. Configure una (o deshabilite la contraseña).</string>
<string name="settings_backup_location_invalid">La ubicación elegida no puede ser usada.</string>
<string name="settings_backup_location_title">Ubicación de la copia de seguridad</string>
<string name="settings_backup_location_picker">Elegir ubicación de la copia de seguridad</string>
<string name="current_destination_string">Estado de la copia de seguridad y ajustes</string>
<string name="data_management_label">Copia de seguridad de Seedvault</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

Some files were not shown because too many files have changed in this diff Show more