diff --git a/app/build.gradle b/app/build.gradle index 4a163d768..ccdc454a6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -77,6 +77,7 @@ android { '-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=coil3.annotation.ExperimentalCoilApi', '-opt-in=coil3.annotation.InternalCoilApi', + '-opt-in=kotlinx.serialization.ExperimentalSerializationApi', '-Xjspecify-annotations=strict', '-Xtype-enhancement-improvements-strict-mode' ] diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 00337a665..3075e06f8 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -20,7 +20,7 @@ -keep class org.koitharu.kotatsu.core.exceptions.* { *; } -keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment -keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; } --keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; } +-keep class org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment { *; } -keep class org.jsoup.parser.Tag -keep class org.jsoup.internal.StringUtil diff --git a/app/src/androidTest/assets/manga/bad_ids.json b/app/src/androidTest/assets/manga/bad_ids.json index d0f9001a0..b2fc2189f 100644 --- a/app/src/androidTest/assets/manga/bad_ids.json +++ b/app/src/androidTest/assets/manga/bad_ids.json @@ -1,6 +1,7 @@ { "id": -2096681732556647985, "title": "Странствия Эманон", + "altTitles": [], "url": "/stranstviia_emanon", "publicUrl": "https://readmanga.io/stranstviia_emanon", "rating": 0.9400894, @@ -29,13 +30,15 @@ } ], "state": "FINISHED", + "authors": [], "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
", "chapters": [ { "id": 1552943969433540704, - "name": "1 - 1", + "title": "1 - 1", "number": 1, + "volume": 0, "url": "/stranstviia_emanon/vol1/1", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -43,8 +46,9 @@ }, { "id": 1552943969433540705, - "name": "1 - 2", + "title": "1 - 2", "number": 2, + "volume": 0, "url": "/stranstviia_emanon/vol1/2", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -52,8 +56,9 @@ }, { "id": 1552943969433540706, - "name": "1 - 3", + "title": "1 - 3", "number": 3, + "volume": 0, "url": "/stranstviia_emanon/vol1/3", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -61,8 +66,9 @@ }, { "id": 1552943969433540707, - "name": "1 - 4", + "title": "1 - 4", "number": 4, + "volume": 0, "url": "/stranstviia_emanon/vol1/4", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -70,8 +76,9 @@ }, { "id": 1552943969433540708, - "name": "1 - 5", + "title": "1 - 5", "number": 5, + "volume": 0, "url": "/stranstviia_emanon/vol1/5", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -79,8 +86,9 @@ }, { "id": 1552943969433541665, - "name": "2 - 1", + "title": "2 - 1", "number": 6, + "volume": 0, "url": "/stranstviia_emanon/vol2/1", "scanlator": "Sup!", "uploadDate": 1415570400000, @@ -88,8 +96,9 @@ }, { "id": 1552943969433541666, - "name": "2 - 2", + "title": "2 - 2", "number": 7, + "volume": 0, "url": "/stranstviia_emanon/vol2/2", "scanlator": "Sup!", "uploadDate": 1419976800000, @@ -97,8 +106,9 @@ }, { "id": 1552943969433541667, - "name": "2 - 3", + "title": "2 - 3", "number": 8, + "volume": 0, "url": "/stranstviia_emanon/vol2/3", "scanlator": "Sup!", "uploadDate": 1427922000000, @@ -106,8 +116,9 @@ }, { "id": 1552943969433541668, - "name": "2 - 4", + "title": "2 - 4", "number": 9, + "volume": 0, "url": "/stranstviia_emanon/vol2/4", "scanlator": "Sup!", "uploadDate": 1436907600000, @@ -115,8 +126,9 @@ }, { "id": 1552943969433541669, - "name": "2 - 5", + "title": "2 - 5", "number": 10, + "volume": 0, "url": "/stranstviia_emanon/vol2/5", "scanlator": "Sup!", "uploadDate": 1446674400000, @@ -124,8 +136,9 @@ }, { "id": 1552943969433541670, - "name": "2 - 6", + "title": "2 - 6", "number": 11, + "volume": 0, "url": "/stranstviia_emanon/vol2/6", "scanlator": "Sup!", "uploadDate": 1451512800000, @@ -133,8 +146,9 @@ }, { "id": 1552943969433542626, - "name": "3 - 1", + "title": "3 - 1", "number": 12, + "volume": 0, "url": "/stranstviia_emanon/vol3/1", "scanlator": "Sup!", "uploadDate": 1461618000000, @@ -142,8 +156,9 @@ }, { "id": 1552943969433542627, - "name": "3 - 2", + "title": "3 - 2", "number": 13, + "volume": 0, "url": "/stranstviia_emanon/vol3/2", "scanlator": "Sup!", "uploadDate": 1461618000000, @@ -151,8 +166,9 @@ }, { "id": 1552943969433542628, - "name": "3 - 3", + "title": "3 - 3", "number": 14, + "volume": 0, "url": "/stranstviia_emanon/vol3/3", "scanlator": "", "uploadDate": 1465851600000, @@ -160,4 +176,4 @@ } ], "source": "READMANGA_RU" -} \ No newline at end of file +} diff --git a/app/src/androidTest/assets/manga/empty.json b/app/src/androidTest/assets/manga/empty.json index 369f0e237..3716b0db7 100644 --- a/app/src/androidTest/assets/manga/empty.json +++ b/app/src/androidTest/assets/manga/empty.json @@ -1,6 +1,7 @@ { "id": -2096681732556647985, "title": "Странствия Эманон", + "altTitles": [], "url": "/stranstviia_emanon", "publicUrl": "https://readmanga.io/stranstviia_emanon", "rating": 0.9400894, @@ -29,8 +30,9 @@ } ], "state": "FINISHED", + "authors": [], "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
", "chapters": [], "source": "READMANGA_RU" -} \ No newline at end of file +} diff --git a/app/src/androidTest/assets/manga/first_chapters.json b/app/src/androidTest/assets/manga/first_chapters.json index 697dec9c8..ed45c3cf6 100644 --- a/app/src/androidTest/assets/manga/first_chapters.json +++ b/app/src/androidTest/assets/manga/first_chapters.json @@ -1,6 +1,7 @@ { "id": -2096681732556647985, "title": "Странствия Эманон", + "altTitles": [], "url": "/stranstviia_emanon", "publicUrl": "https://readmanga.io/stranstviia_emanon", "rating": 0.9400894, @@ -29,13 +30,15 @@ } ], "state": "FINISHED", + "authors": [], "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
", "chapters": [ { "id": 3552943969433540704, - "name": "1 - 1", + "title": "1 - 1", "number": 1, + "volume": 0, "url": "/stranstviia_emanon/vol1/1", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -43,8 +46,9 @@ }, { "id": 3552943969433540705, - "name": "1 - 2", + "title": "1 - 2", "number": 2, + "volume": 0, "url": "/stranstviia_emanon/vol1/2", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -52,8 +56,9 @@ }, { "id": 3552943969433540706, - "name": "1 - 3", + "title": "1 - 3", "number": 3, + "volume": 0, "url": "/stranstviia_emanon/vol1/3", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -61,8 +66,9 @@ }, { "id": 3552943969433540707, - "name": "1 - 4", + "title": "1 - 4", "number": 4, + "volume": 0, "url": "/stranstviia_emanon/vol1/4", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -70,8 +76,9 @@ }, { "id": 3552943969433540708, - "name": "1 - 5", + "title": "1 - 5", "number": 5, + "volume": 0, "url": "/stranstviia_emanon/vol1/5", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -79,8 +86,9 @@ }, { "id": 3552943969433541665, - "name": "2 - 1", + "title": "2 - 1", "number": 6, + "volume": 0, "url": "/stranstviia_emanon/vol2/1", "scanlator": "Sup!", "uploadDate": 1415570400000, @@ -88,8 +96,9 @@ }, { "id": 3552943969433541666, - "name": "2 - 2", + "title": "2 - 2", "number": 7, + "volume": 0, "url": "/stranstviia_emanon/vol2/2", "scanlator": "Sup!", "uploadDate": 1419976800000, @@ -97,8 +106,9 @@ }, { "id": 3552943969433541667, - "name": "2 - 3", + "title": "2 - 3", "number": 8, + "volume": 0, "url": "/stranstviia_emanon/vol2/3", "scanlator": "Sup!", "uploadDate": 1427922000000, @@ -106,8 +116,9 @@ }, { "id": 3552943969433541668, - "name": "2 - 4", + "title": "2 - 4", "number": 9, + "volume": 0, "url": "/stranstviia_emanon/vol2/4", "scanlator": "Sup!", "uploadDate": 1436907600000, @@ -115,8 +126,9 @@ }, { "id": 3552943969433541669, - "name": "2 - 5", + "title": "2 - 5", "number": 10, + "volume": 0, "url": "/stranstviia_emanon/vol2/5", "scanlator": "Sup!", "uploadDate": 1446674400000, @@ -124,8 +136,9 @@ }, { "id": 3552943969433541670, - "name": "2 - 6", + "title": "2 - 6", "number": 11, + "volume": 0, "url": "/stranstviia_emanon/vol2/6", "scanlator": "Sup!", "uploadDate": 1451512800000, @@ -133,4 +146,4 @@ } ], "source": "READMANGA_RU" -} \ No newline at end of file +} diff --git a/app/src/androidTest/assets/manga/full.json b/app/src/androidTest/assets/manga/full.json index 9667baa9c..194bf6cd8 100644 --- a/app/src/androidTest/assets/manga/full.json +++ b/app/src/androidTest/assets/manga/full.json @@ -1,6 +1,7 @@ { "id": -2096681732556647985, "title": "Странствия Эманон", + "altTitles": [], "url": "/stranstviia_emanon", "publicUrl": "https://readmanga.io/stranstviia_emanon", "rating": 0.9400894, @@ -29,13 +30,15 @@ } ], "state": "FINISHED", + "authors": [], "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
", "chapters": [ { "id": 3552943969433540704, - "name": "1 - 1", + "title": "1 - 1", "number": 1, + "volume": 0, "url": "/stranstviia_emanon/vol1/1", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -43,8 +46,9 @@ }, { "id": 3552943969433540705, - "name": "1 - 2", + "title": "1 - 2", "number": 2, + "volume": 0, "url": "/stranstviia_emanon/vol1/2", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -52,8 +56,9 @@ }, { "id": 3552943969433540706, - "name": "1 - 3", + "title": "1 - 3", "number": 3, + "volume": 0, "url": "/stranstviia_emanon/vol1/3", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -61,8 +66,9 @@ }, { "id": 3552943969433540707, - "name": "1 - 4", + "title": "1 - 4", "number": 4, + "volume": 0, "url": "/stranstviia_emanon/vol1/4", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -70,8 +76,9 @@ }, { "id": 3552943969433540708, - "name": "1 - 5", + "title": "1 - 5", "number": 5, + "volume": 0, "url": "/stranstviia_emanon/vol1/5", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -79,8 +86,9 @@ }, { "id": 3552943969433541665, - "name": "2 - 1", + "title": "2 - 1", "number": 6, + "volume": 0, "url": "/stranstviia_emanon/vol2/1", "scanlator": "Sup!", "uploadDate": 1415570400000, @@ -88,8 +96,9 @@ }, { "id": 3552943969433541666, - "name": "2 - 2", + "title": "2 - 2", "number": 7, + "volume": 0, "url": "/stranstviia_emanon/vol2/2", "scanlator": "Sup!", "uploadDate": 1419976800000, @@ -97,8 +106,9 @@ }, { "id": 3552943969433541667, - "name": "2 - 3", + "title": "2 - 3", "number": 8, + "volume": 0, "url": "/stranstviia_emanon/vol2/3", "scanlator": "Sup!", "uploadDate": 1427922000000, @@ -106,8 +116,9 @@ }, { "id": 3552943969433541668, - "name": "2 - 4", + "title": "2 - 4", "number": 9, + "volume": 0, "url": "/stranstviia_emanon/vol2/4", "scanlator": "Sup!", "uploadDate": 1436907600000, @@ -115,8 +126,9 @@ }, { "id": 3552943969433541669, - "name": "2 - 5", + "title": "2 - 5", "number": 10, + "volume": 0, "url": "/stranstviia_emanon/vol2/5", "scanlator": "Sup!", "uploadDate": 1446674400000, @@ -124,8 +136,9 @@ }, { "id": 3552943969433541670, - "name": "2 - 6", + "title": "2 - 6", "number": 11, + "volume": 0, "url": "/stranstviia_emanon/vol2/6", "scanlator": "Sup!", "uploadDate": 1451512800000, @@ -133,8 +146,9 @@ }, { "id": 3552943969433542626, - "name": "3 - 1", + "title": "3 - 1", "number": 12, + "volume": 0, "url": "/stranstviia_emanon/vol3/1", "scanlator": "Sup!", "uploadDate": 1461618000000, @@ -142,8 +156,9 @@ }, { "id": 3552943969433542627, - "name": "3 - 2", + "title": "3 - 2", "number": 13, + "volume": 0, "url": "/stranstviia_emanon/vol3/2", "scanlator": "Sup!", "uploadDate": 1461618000000, @@ -151,8 +166,9 @@ }, { "id": 3552943969433542628, - "name": "3 - 3", + "title": "3 - 3", "number": 14, + "volume": 0, "url": "/stranstviia_emanon/vol3/3", "scanlator": "", "uploadDate": 1465851600000, @@ -160,4 +176,4 @@ } ], "source": "READMANGA_RU" -} \ No newline at end of file +} diff --git a/app/src/androidTest/assets/manga/header.json b/app/src/androidTest/assets/manga/header.json index dc56dbf8e..061d634bf 100644 --- a/app/src/androidTest/assets/manga/header.json +++ b/app/src/androidTest/assets/manga/header.json @@ -1,6 +1,7 @@ { "id": -2096681732556647985, "title": "Странствия Эманон", + "altTitles": [], "url": "/stranstviia_emanon", "publicUrl": "https://readmanga.io/stranstviia_emanon", "rating": 0.9400894, @@ -29,7 +30,8 @@ } ], "state": "FINISHED", + "authors": [], "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", "description": null, "source": "READMANGA_RU" -} \ No newline at end of file +} diff --git a/app/src/androidTest/assets/manga/without_middle_chapter.json b/app/src/androidTest/assets/manga/without_middle_chapter.json index 97d797b53..1e7c5d90f 100644 --- a/app/src/androidTest/assets/manga/without_middle_chapter.json +++ b/app/src/androidTest/assets/manga/without_middle_chapter.json @@ -1,6 +1,7 @@ { "id": -2096681732556647985, "title": "Странствия Эманон", + "altTitles": [], "url": "/stranstviia_emanon", "publicUrl": "https://readmanga.io/stranstviia_emanon", "rating": 0.9400894, @@ -29,13 +30,15 @@ } ], "state": "FINISHED", + "authors": [], "largeCoverUrl": "https://staticrm.rmr.rocks/uploads/pics/01/12/559_o.jpg", "description": "Продолжение истории о загадочной девушке по имени Эманон, которая помнит всё, что происходило на Земле за последние три миллиарда лет. \n
Начало истории читайте в \"Воспоминаниях Эманон\". \n
", "chapters": [ { "id": 3552943969433540704, - "name": "1 - 1", + "title": "1 - 1", "number": 1, + "volume": 0, "url": "/stranstviia_emanon/vol1/1", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -43,8 +46,9 @@ }, { "id": 3552943969433540705, - "name": "1 - 2", + "title": "1 - 2", "number": 2, + "volume": 0, "url": "/stranstviia_emanon/vol1/2", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -52,8 +56,9 @@ }, { "id": 3552943969433540706, - "name": "1 - 3", + "title": "1 - 3", "number": 3, + "volume": 0, "url": "/stranstviia_emanon/vol1/3", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -61,8 +66,9 @@ }, { "id": 3552943969433540707, - "name": "1 - 4", + "title": "1 - 4", "number": 4, + "volume": 0, "url": "/stranstviia_emanon/vol1/4", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -70,8 +76,9 @@ }, { "id": 3552943969433540708, - "name": "1 - 5", + "title": "1 - 5", "number": 5, + "volume": 0, "url": "/stranstviia_emanon/vol1/5", "scanlator": "Sad-Robot", "uploadDate": 1342731600000, @@ -79,8 +86,9 @@ }, { "id": 3552943969433541666, - "name": "2 - 2", + "title": "2 - 2", "number": 7, + "volume": 0, "url": "/stranstviia_emanon/vol2/2", "scanlator": "Sup!", "uploadDate": 1419976800000, @@ -88,8 +96,9 @@ }, { "id": 3552943969433541667, - "name": "2 - 3", + "title": "2 - 3", "number": 8, + "volume": 0, "url": "/stranstviia_emanon/vol2/3", "scanlator": "Sup!", "uploadDate": 1427922000000, @@ -97,8 +106,9 @@ }, { "id": 3552943969433541668, - "name": "2 - 4", + "title": "2 - 4", "number": 9, + "volume": 0, "url": "/stranstviia_emanon/vol2/4", "scanlator": "Sup!", "uploadDate": 1436907600000, @@ -106,8 +116,9 @@ }, { "id": 3552943969433541669, - "name": "2 - 5", + "title": "2 - 5", "number": 10, + "volume": 0, "url": "/stranstviia_emanon/vol2/5", "scanlator": "Sup!", "uploadDate": 1446674400000, @@ -115,8 +126,9 @@ }, { "id": 3552943969433541670, - "name": "2 - 6", + "title": "2 - 6", "number": 11, + "volume": 0, "url": "/stranstviia_emanon/vol2/6", "scanlator": "Sup!", "uploadDate": 1451512800000, @@ -124,8 +136,9 @@ }, { "id": 3552943969433542626, - "name": "3 - 1", + "title": "3 - 1", "number": 12, + "volume": 0, "url": "/stranstviia_emanon/vol3/1", "scanlator": "Sup!", "uploadDate": 1461618000000, @@ -133,8 +146,9 @@ }, { "id": 3552943969433542627, - "name": "3 - 2", + "title": "3 - 2", "number": 13, + "volume": 0, "url": "/stranstviia_emanon/vol3/2", "scanlator": "Sup!", "uploadDate": 1461618000000, @@ -142,8 +156,9 @@ }, { "id": 3552943969433542628, - "name": "3 - 3", + "title": "3 - 3", "number": 14, + "volume": 0, "url": "/stranstviia_emanon/vol3/3", "scanlator": "", "uploadDate": 1465851600000, @@ -151,4 +166,4 @@ } ], "source": "READMANGA_RU" -} \ No newline at end of file +} diff --git a/app/src/androidTest/kotlin/org/koitharu/kotatsu/SampleData.kt b/app/src/androidTest/kotlin/org/koitharu/kotatsu/SampleData.kt index b7d4ad7e3..11e1a48f7 100644 --- a/app/src/androidTest/kotlin/org/koitharu/kotatsu/SampleData.kt +++ b/app/src/androidTest/kotlin/org/koitharu/kotatsu/SampleData.kt @@ -1,19 +1,29 @@ package org.koitharu.kotatsu import androidx.test.platform.app.InstrumentationRegistry -import com.squareup.moshi.* +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.ToJson import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import okio.buffer import okio.source import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.parsers.model.Manga -import java.util.* +import org.koitharu.kotatsu.parsers.model.MangaSource +import java.time.Instant +import java.util.Date import kotlin.reflect.KClass object SampleData { private val moshi = Moshi.Builder() .add(DateAdapter()) + .add(InstantAdapter()) + .add(MangaSourceAdapter()) .add(KotlinJsonAdapterFactory()) .build() @@ -51,4 +61,36 @@ object SampleData { writer.value(value?.time ?: 0L) } } -} \ No newline at end of file + + private class MangaSourceAdapter : JsonAdapter() { + + @FromJson + override fun fromJson(reader: JsonReader): MangaSource? { + val name = reader.nextString() ?: return null + return MangaSource(name) + } + + @ToJson + override fun toJson(writer: JsonWriter, value: MangaSource?) { + writer.value(value?.name) + } + } + + private class InstantAdapter : JsonAdapter() { + + @FromJson + override fun fromJson(reader: JsonReader): Instant? { + val ms = reader.nextLong() + return if (ms == 0L) { + null + } else { + Instant.ofEpochMilli(ms) + } + } + + @ToJson + override fun toJson(writer: JsonWriter, value: Instant?) { + writer.value(value?.toEpochMilli() ?: 0L) + } + } +} diff --git a/app/src/androidTest/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt b/app/src/androidTest/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt index f01cc9521..22912dcd7 100644 --- a/app/src/androidTest/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt +++ b/app/src/androidTest/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgentTest.kt @@ -15,7 +15,8 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.koitharu.kotatsu.SampleData -import org.koitharu.kotatsu.core.backup.BackupRepository +import org.koitharu.kotatsu.backups.data.BackupRepository +import org.koitharu.kotatsu.backups.domain.AppBackupAgent import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.favourites.domain.FavouritesRepository diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b936a49d0..1410f3e96 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,7 +48,7 @@ + android:label="@string/creating_backup" /> + ? + ) { + progress?.emit(Progress.INDETERMINATE) + var commonProgress = Progress(0, BackupSection.entries.size) + for (section in BackupSection.entries) { + when (section) { + BackupSection.INDEX -> output.writeJsonArray( + section = BackupSection.INDEX, + data = flowOf(BackupIndex()), + serializer = serializer(), + ) + + BackupSection.HISTORY -> output.writeJsonArray( + section = BackupSection.HISTORY, + data = database.getHistoryDao().dump().map { HistoryBackup(it) }, + serializer = serializer(), + ) + + BackupSection.CATEGORIES -> output.writeJsonArray( + section = BackupSection.CATEGORIES, + data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) }, + serializer = serializer(), + ) + + BackupSection.FAVOURITES -> output.writeJsonArray( + section = BackupSection.FAVOURITES, + data = database.getFavouritesDao().dump().map { FavouriteBackup(it) }, + serializer = serializer(), + ) + + BackupSection.SETTINGS -> output.writeString( + section = BackupSection.SETTINGS, + data = dumpSettings(), + ) + + BackupSection.SETTINGS_READER_GRID -> output.writeString( + section = BackupSection.SETTINGS_READER_GRID, + data = dumpReaderGridSettings(), + ) + + BackupSection.BOOKMARKS -> output.writeJsonArray( + section = BackupSection.BOOKMARKS, + data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) }, + serializer = serializer(), + ) + + BackupSection.SOURCES -> output.writeJsonArray( + section = BackupSection.SOURCES, + data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) }, + serializer = serializer(), + ) + } + progress?.emit(commonProgress) + commonProgress++ + } + progress?.emit(commonProgress) + } + + suspend fun restoreBackup( + input: ZipInputStream, + sections: Set, + progress: FlowCollector? + ): CompositeResult { + progress?.emit(Progress.INDETERMINATE) + var commonProgress = Progress(0, sections.size) + var entry = input.nextEntry + var result = CompositeResult.EMPTY + while (entry != null) { + val section = BackupSection.of(entry) + if (section in sections) { + result = result + when (section) { + BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case + BackupSection.HISTORY -> input.readJsonArray(serializer()).restoreToDb { + upsertManga(it.manga) + getHistoryDao().upsert(it.toEntity()) + } + + BackupSection.CATEGORIES -> input.readJsonArray(serializer()).restoreToDb { + getFavouriteCategoriesDao().upsert(it.toEntity()) + } + + BackupSection.FAVOURITES -> input.readJsonArray(serializer()).restoreToDb { + upsertManga(it.manga) + getFavouritesDao().upsert(it.toEntity()) + } + + BackupSection.SETTINGS -> input.readMap().let { + settings.upsertAll(it) + CompositeResult.success() + } + + BackupSection.SETTINGS_READER_GRID -> input.readMap().let { + tapGridSettings.upsertAll(it) + CompositeResult.success() + } + + BackupSection.BOOKMARKS -> input.readJsonArray(serializer()).restoreToDb { + upsertManga(it.manga) + getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() }) + } + + BackupSection.SOURCES -> input.readJsonArray(serializer()).restoreToDb { + getSourcesDao().upsert(it.toEntity()) + } + + null -> CompositeResult.EMPTY // skip unknown entries + } + progress?.emit(commonProgress) + commonProgress++ + } + input.closeEntry() + entry = input.nextEntry + } + progress?.emit(commonProgress) + return result + } + + private suspend fun ZipOutputStream.writeJsonArray( + section: BackupSection, + data: Flow, + serializer: SerializationStrategy, + ) { + data.onStart { + putNextEntry(ZipEntry(section.entryName)) + write("[") + }.onCompletion { + write("]") + closeEntry() + flush() + }.collectIndexed { index, value -> + if (index > 0) { + write(",") + } + Json.encodeToStream(serializer, value, this) + } + } + + private fun InputStream.readJsonArray( + serializer: DeserializationStrategy, + ): Sequence = Json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED) + + private fun InputStream.readMap(): Map { + val jo = JSONArray(readString()).getJSONObject(0) + val map = ArrayMap(jo.length()) + val keys = jo.keys() + while (keys.hasNext()) { + val key = keys.next() + map[key] = jo.get(key) + } + return map + } + + private fun ZipOutputStream.writeString( + section: BackupSection, + data: String, + ) { + putNextEntry(ZipEntry(section.entryName)) + try { + write("[") + write(data) + write("]") + } finally { + closeEntry() + flush() + } + } + + private fun OutputStream.write(str: String) = write(str.toByteArray()) + + private fun InputStream.readString(): String = readBytes().decodeToString() + + private fun dumpSettings(): String { + val map = settings.getAllValues().toMutableMap() + map.remove(AppSettings.KEY_APP_PASSWORD) + map.remove(AppSettings.KEY_PROXY_PASSWORD) + map.remove(AppSettings.KEY_PROXY_LOGIN) + map.remove(AppSettings.KEY_INCOGNITO_MODE) + return JSONObject(map).toString() + } + + private fun dumpReaderGridSettings(): String { + return JSONObject(tapGridSettings.getAllValues()).toString() + } + + private suspend fun MangaDatabase.upsertManga(manga: MangaBackup) { + val tags = manga.tags.map { it.toEntity() } + getTagsDao().upsert(tags) + getMangaDao().upsert(manga.toEntity(), tags) + } + + private suspend inline fun Sequence.restoreToDb(crossinline block: suspend MangaDatabase.(T) -> Unit): CompositeResult { + return fold(CompositeResult.EMPTY) { result, item -> + result + runCatchingCancellable { + database.withTransaction { + database.block(item) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/BackupIndex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/BackupIndex.kt new file mode 100644 index 000000000..14e84d19f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/BackupIndex.kt @@ -0,0 +1,19 @@ +package org.koitharu.kotatsu.backups.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koitharu.kotatsu.BuildConfig + +@Serializable +class BackupIndex( + @SerialName("app_id") val appId: String, + @SerialName("app_version") val appVersion: Int, + @SerialName("created_at") val createdAt: Long, +) { + + constructor() : this( + appId = BuildConfig.APPLICATION_ID, + appVersion = BuildConfig.VERSION_CODE, + createdAt = System.currentTimeMillis(), + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/BookmarkBackup.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/BookmarkBackup.kt new file mode 100644 index 000000000..8c6ab56ab --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/BookmarkBackup.kt @@ -0,0 +1,56 @@ +package org.koitharu.kotatsu.backups.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity +import org.koitharu.kotatsu.core.db.entity.MangaWithTags +import org.koitharu.kotatsu.parsers.util.mapToSet + +@Serializable +class BookmarkBackup( + @SerialName("manga") val manga: MangaBackup, + @SerialName("tags") val tags: Set, + @SerialName("bookmarks") val bookmarks: List, +) { + + @Serializable + class Bookmark( + @SerialName("manga_id") val mangaId: Long, + @SerialName("page_id") val pageId: Long, + @SerialName("chapter_id") val chapterId: Long, + @SerialName("page") val page: Int, + @SerialName("scroll") val scroll: Int, + @SerialName("image") val imageUrl: String, + @SerialName("created_at") val createdAt: Long, + @SerialName("percent") val percent: Float, + ) { + + fun toEntity() = BookmarkEntity( + mangaId = mangaId, + pageId = pageId, + chapterId = chapterId, + page = page, + scroll = scroll, + imageUrl = imageUrl, + createdAt = createdAt, + percent = percent, + ) + } + + constructor(manga: MangaWithTags, entities: List) : this( + manga = MangaBackup(manga.copy(tags = emptyList())), + tags = manga.tags.mapToSet { TagBackup(it) }, + bookmarks = entities.map { + Bookmark( + mangaId = it.mangaId, + pageId = it.pageId, + chapterId = it.chapterId, + page = it.page, + scroll = it.scroll, + imageUrl = it.imageUrl, + createdAt = it.createdAt, + percent = it.percent, + ) + }, + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/CategoryBackup.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/CategoryBackup.kt new file mode 100644 index 000000000..b90f05228 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/CategoryBackup.kt @@ -0,0 +1,39 @@ +package org.koitharu.kotatsu.backups.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity +import org.koitharu.kotatsu.list.domain.ListSortOrder + +@Serializable +class CategoryBackup( + @SerialName("category_id") val categoryId: Int, + @SerialName("created_at") val createdAt: Long, + @SerialName("sort_key") val sortKey: Int, + @SerialName("title") val title: String, + @SerialName("order") val order: String = ListSortOrder.NEWEST.name, + @SerialName("track") val track: Boolean = true, + @SerialName("show_in_lib") val isVisibleInLibrary: Boolean = true, +) { + + constructor(entity: FavouriteCategoryEntity) : this( + categoryId = entity.categoryId, + createdAt = entity.createdAt, + sortKey = entity.sortKey, + title = entity.title, + order = entity.order, + track = entity.track, + isVisibleInLibrary = entity.isVisibleInLibrary, + ) + + fun toEntity() = FavouriteCategoryEntity( + categoryId = categoryId, + createdAt = createdAt, + sortKey = sortKey, + title = title, + order = order, + track = track, + isVisibleInLibrary = isVisibleInLibrary, + deletedAt = 0L, + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/FavouriteBackup.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/FavouriteBackup.kt new file mode 100644 index 000000000..1eca82071 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/FavouriteBackup.kt @@ -0,0 +1,36 @@ +package org.koitharu.kotatsu.backups.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koitharu.kotatsu.core.db.entity.MangaWithTags +import org.koitharu.kotatsu.favourites.data.FavouriteEntity +import org.koitharu.kotatsu.favourites.data.FavouriteManga + +@Serializable +class FavouriteBackup( + @SerialName("manga_id") val mangaId: Long, + @SerialName("category_id") val categoryId: Long, + @SerialName("sort_key") val sortKey: Int = 0, + @SerialName("pinned") val isPinned: Boolean = false, + @SerialName("created_at") val createdAt: Long, + @SerialName("manga") val manga: MangaBackup, +) { + + constructor(entity: FavouriteManga) : this( + mangaId = entity.manga.id, + categoryId = entity.favourite.categoryId, + sortKey = entity.favourite.sortKey, + isPinned = entity.favourite.isPinned, + createdAt = entity.favourite.createdAt, + manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)), + ) + + fun toEntity() = FavouriteEntity( + mangaId = mangaId, + categoryId = categoryId, + sortKey = sortKey, + isPinned = isPinned, + createdAt = createdAt, + deletedAt = 0L, + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/HistoryBackup.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/HistoryBackup.kt new file mode 100644 index 000000000..ce77ce15f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/HistoryBackup.kt @@ -0,0 +1,46 @@ +package org.koitharu.kotatsu.backups.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koitharu.kotatsu.core.db.entity.MangaWithTags +import org.koitharu.kotatsu.history.data.HistoryEntity +import org.koitharu.kotatsu.history.data.HistoryWithManga +import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE + +@Serializable +class HistoryBackup( + @SerialName("manga_id") val mangaId: Long, + @SerialName("created_at") val createdAt: Long, + @SerialName("updated_at") val updatedAt: Long, + @SerialName("chapter_id") val chapterId: Long, + @SerialName("page") val page: Int, + @SerialName("scroll") val scroll: Float, + @SerialName("percent") val percent: Float = PROGRESS_NONE, + @SerialName("chapters") val chaptersCount: Int = 0, + @SerialName("manga") val manga: MangaBackup, +) { + + constructor(entity: HistoryWithManga) : this( + mangaId = entity.manga.id, + createdAt = entity.history.createdAt, + updatedAt = entity.history.updatedAt, + chapterId = entity.history.chapterId, + page = entity.history.page, + scroll = entity.history.scroll, + percent = entity.history.percent, + chaptersCount = entity.history.chaptersCount, + manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)), + ) + + fun toEntity() = HistoryEntity( + mangaId = mangaId, + createdAt = createdAt, + updatedAt = updatedAt, + chapterId = chapterId, + page = page, + scroll = scroll, + percent = percent, + deletedAt = 0L, + chaptersCount = chaptersCount, + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/MangaBackup.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/MangaBackup.kt new file mode 100644 index 000000000..2578fc2ef --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/MangaBackup.kt @@ -0,0 +1,60 @@ +package org.koitharu.kotatsu.backups.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.MangaWithTags +import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN +import org.koitharu.kotatsu.parsers.util.mapToSet + +@Serializable +class MangaBackup( + @SerialName("id") val id: Long, + @SerialName("title") val title: String, + @SerialName("alt_title") val altTitles: String? = null, + @SerialName("url") val url: String, + @SerialName("public_url") val publicUrl: String, + @SerialName("rating") val rating: Float = RATING_UNKNOWN, + @SerialName("nsfw") val isNsfw: Boolean = false, + @SerialName("content_rating") val contentRating: String? = null, + @SerialName("cover_url") val coverUrl: String, + @SerialName("large_cover_url") val largeCoverUrl: String? = null, + @SerialName("state") val state: String? = null, + @SerialName("author") val authors: String? = null, + @SerialName("source") val source: String, + @SerialName("tags") val tags: Set = emptySet(), +) { + + constructor(entity: MangaWithTags) : this( + id = entity.manga.id, + title = entity.manga.title, + altTitles = entity.manga.altTitles, + url = entity.manga.url, + publicUrl = entity.manga.publicUrl, + rating = entity.manga.rating, + isNsfw = entity.manga.isNsfw, + contentRating = entity.manga.contentRating, + coverUrl = entity.manga.coverUrl, + largeCoverUrl = entity.manga.largeCoverUrl, + state = entity.manga.state, + authors = entity.manga.authors, + source = entity.manga.source, + tags = entity.tags.mapToSet { TagBackup(it) }, + ) + + fun toEntity() = MangaEntity( + id = id, + title = title, + altTitles = altTitles, + url = url, + publicUrl = publicUrl, + rating = rating, + isNsfw = isNsfw, + contentRating = contentRating, + coverUrl = coverUrl, + largeCoverUrl = largeCoverUrl, + state = state, + authors = authors, + source = source, + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/SourceBackup.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/SourceBackup.kt new file mode 100644 index 000000000..0d6d29a4a --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/SourceBackup.kt @@ -0,0 +1,35 @@ +package org.koitharu.kotatsu.backups.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity + +@Serializable +class SourceBackup( + @SerialName("source") val source: String, + @SerialName("sort_key") val sortKey: Int, + @SerialName("used_at") val lastUsedAt: Long, + @SerialName("added_in") val addedIn: Int, + @SerialName("pinned") val isPinned: Boolean = false, + @SerialName("enabled") val isEnabled: Boolean = true, // for compatibility purposes, should be only true +) { + + constructor(entity: MangaSourceEntity) : this( + source = entity.source, + sortKey = entity.sortKey, + lastUsedAt = entity.lastUsedAt, + addedIn = entity.addedIn, + isPinned = entity.isPinned, + isEnabled = entity.isEnabled, + ) + + fun toEntity() = MangaSourceEntity( + source = source, + isEnabled = isEnabled, + sortKey = sortKey, + addedIn = addedIn, + lastUsedAt = lastUsedAt, + isPinned = isPinned, + cfState = 0, + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/TagBackup.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/TagBackup.kt new file mode 100644 index 000000000..6efe6d083 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/data/model/TagBackup.kt @@ -0,0 +1,31 @@ +package org.koitharu.kotatsu.backups.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koitharu.kotatsu.core.db.entity.TagEntity + +@Serializable +class TagBackup( + @SerialName("id") val id: Long, + @SerialName("title") val title: String, + @SerialName("key") val key: String, + @SerialName("source") val source: String, + @SerialName("pinned") val isPinned: Boolean = false, +) { + + constructor(entity: TagEntity) : this( + id = entity.id, + title = entity.title, + key = entity.key, + source = entity.source, + isPinned = entity.isPinned, + ) + + fun toEntity() = TagEntity( + id = id, + title = title, + key = key, + source = source, + isPinned = isPinned, + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/AppBackupAgent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/AppBackupAgent.kt new file mode 100644 index 000000000..d63e22a66 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/AppBackupAgent.kt @@ -0,0 +1,98 @@ +package org.koitharu.kotatsu.backups.domain + +import android.app.backup.BackupAgent +import android.app.backup.BackupDataInput +import android.app.backup.BackupDataOutput +import android.app.backup.FullBackupDataOutput +import android.content.Context +import android.os.ParcelFileDescriptor +import androidx.annotation.VisibleForTesting +import com.google.common.io.ByteStreams +import kotlinx.coroutines.runBlocking +import org.koitharu.kotatsu.backups.data.BackupRepository +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.reader.data.TapGridSettings +import java.io.File +import java.io.FileDescriptor +import java.io.FileInputStream +import java.util.EnumSet +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +class AppBackupAgent : BackupAgent() { + + override fun onBackup( + oldState: ParcelFileDescriptor?, + data: BackupDataOutput?, + newState: ParcelFileDescriptor? + ) = Unit + + override fun onRestore( + data: BackupDataInput?, + appVersionCode: Int, + newState: ParcelFileDescriptor? + ) = Unit + + override fun onFullBackup(data: FullBackupDataOutput) { + super.onFullBackup(data) + val file = + createBackupFile( + this, + BackupRepository( + MangaDatabase(context = applicationContext), + AppSettings(applicationContext), + TapGridSettings(applicationContext), + ), + ) + try { + fullBackupFile(file, data) + } finally { + file.delete() + } + } + + override fun onRestoreFile( + data: ParcelFileDescriptor, + size: Long, + destination: File?, + type: Int, + mode: Long, + mtime: Long + ) { + if (destination?.name?.endsWith(".bk.zip") == true) { + restoreBackupFile( + data.fileDescriptor, + size, + BackupRepository( + database = MangaDatabase(applicationContext), + settings = AppSettings(applicationContext), + tapGridSettings = TapGridSettings(applicationContext), + ), + ) + destination.delete() + } else { + super.onRestoreFile(data, size, destination, type, mode, mtime) + } + } + + @VisibleForTesting + fun createBackupFile(context: Context, repository: BackupRepository): File { + val file = BackupUtils.createTempFile(context) + ZipOutputStream(file.outputStream()).use { output -> + runBlocking { + repository.createBackup(output, null) + } + } + return file + } + + @VisibleForTesting + fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) { + ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input -> + runBlocking { + repository.restoreBackup(input, EnumSet.allOf(BackupSection::class.java), null) + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupFile.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupFile.kt similarity index 73% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupFile.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupFile.kt index 1fb044ad1..18117b716 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupFile.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupFile.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.backup +package org.koitharu.kotatsu.backups.domain import android.net.Uri import java.util.Date @@ -6,7 +6,7 @@ import java.util.Date data class BackupFile( val uri: Uri, val dateTime: Date, -): Comparable { +) : Comparable { override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupObserver.kt similarity index 79% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupObserver.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupObserver.kt index 595c27e88..9cd548ca9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupObserver.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.settings.backup +package org.koitharu.kotatsu.backups.domain import android.app.backup.BackupManager import android.content.Context @@ -13,7 +13,13 @@ import javax.inject.Singleton @Singleton class BackupObserver @Inject constructor( @ApplicationContext context: Context, -) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) { +) : InvalidationTracker.Observer( + arrayOf( + TABLE_HISTORY, + TABLE_FAVOURITES, + TABLE_FAVOURITE_CATEGORIES, + ), +) { private val backupManager = BackupManager(context) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupSection.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupSection.kt new file mode 100644 index 000000000..b418787da --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupSection.kt @@ -0,0 +1,27 @@ +package org.koitharu.kotatsu.backups.domain + +import java.util.Locale +import java.util.zip.ZipEntry + +enum class BackupSection( + val entryName: String, +) { + + INDEX("index"), + HISTORY("history"), + CATEGORIES("categories"), + FAVOURITES("favourites"), + SETTINGS("settings"), + SETTINGS_READER_GRID("reader_grid"), + BOOKMARKS("bookmarks"), + SOURCES("sources"), + ; + + companion object { + + fun of(entry: ZipEntry): BackupSection? { + val name = entry.name.lowercase(Locale.ROOT) + return entries.first { x -> x.entryName == name } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupUtils.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupUtils.kt new file mode 100644 index 000000000..9974b473c --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/BackupUtils.kt @@ -0,0 +1,42 @@ +package org.koitharu.kotatsu.backups.domain + +import android.content.Context +import androidx.annotation.CheckResult +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import java.io.File +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object BackupUtils { + + private const val DIR_BACKUPS = "backups" + private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm") + + @CheckResult + fun createTempFile(context: Context): File { + val dir = getAppBackupDir(context) + dir.mkdirs() + return File(dir, generateFileName(context)) + } + + fun getAppBackupDir(context: Context) = context.run { + getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) + } + + fun parseBackupDateTime(fileName: String): Date? = try { + dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.')) + } catch (e: ParseException) { + e.printStackTraceDebug() + null + } + + fun generateFileName(context: Context) = buildString { + append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT)) + append('_') + append(dateTimeFormat.format(Date())) + append(".bk.zip") + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/ExternalBackupStorage.kt similarity index 91% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/ExternalBackupStorage.kt index 4c6982d4b..fb712c333 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/domain/ExternalBackupStorage.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.backup +package org.koitharu.kotatsu.backups.domain import android.content.Context import android.net.Uri @@ -28,7 +28,7 @@ class ExternalBackupStorage @Inject constructor( BackupFile( uri = it.uri, dateTime = it.name?.let { fileName -> - BackupZipOutput.parseBackupDateTime(fileName) + BackupUtils.parseBackupDateTime(fileName) } ?: return@mapNotNull null, ) } else { @@ -44,7 +44,12 @@ class ExternalBackupStorage @Inject constructor( }.getOrNull() suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) { - val out = checkNotNull(getRootOrThrow().createFile("application/zip", file.nameWithoutExtension)) { + val out = checkNotNull( + getRootOrThrow().createFile( + "application/zip", + file.nameWithoutExtension, + ), + ) { "Cannot create target backup file" } checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/BaseBackupRestoreService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/BaseBackupRestoreService.kt new file mode 100644 index 000000000..97741b0f6 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/BaseBackupRestoreService.kt @@ -0,0 +1,77 @@ +package org.koitharu.kotatsu.backups.ui + +import android.app.Notification +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ErrorReporterReceiver +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.ui.CoroutineIntentService +import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission +import org.koitharu.kotatsu.core.util.ext.getDisplayMessage + +abstract class BaseBackupRestoreService : CoroutineIntentService() { + + protected abstract val notificationTag: String + + protected lateinit var notificationManager: NotificationManagerCompat + private set + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(applicationContext) + createNotificationChannel() + } + + override fun IntentJobContext.onError(error: Throwable) { + if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { + val notification = createErrorNotification(error) + notificationManager.notify(notificationTag, startId, notification) + } + } + + private fun createNotificationChannel() { + val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH) + .setName(getString(R.string.backup_restore)) + .setShowBadge(true) + .setVibrationEnabled(false) + .setSound(null, null) + .setLightsEnabled(false) + .build() + notificationManager.createNotificationChannel(channel) + } + + protected fun createErrorNotification(error: Throwable): Notification { + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(0) + .setSilent(true) + .setAutoCancel(true) + .setContentText(error.getDisplayMessage(resources)) + .setSmallIcon(android.R.drawable.stat_notify_error) + ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent -> + notification.addAction( + R.drawable.ic_alert_outline, + applicationContext.getString(R.string.report), + reportIntent, + ) + } + notification.setContentIntent( + PendingIntentCompat.getActivity( + applicationContext, + 0, + AppRouter.homeIntent(this), + 0, + false, + ), + ) + return notification.build() + } + + protected companion object { + + const val CHANNEL_ID = "backup_restore" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/backup/BackupDialogFragment.kt similarity index 76% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/backup/BackupDialogFragment.kt index c26c02f98..80477eab0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/backup/BackupDialogFragment.kt @@ -1,10 +1,10 @@ -package org.koitharu.kotatsu.settings.backup +package org.koitharu.kotatsu.backups.ui.backup +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.isVisible import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -14,26 +14,14 @@ import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.core.util.progress.Progress import org.koitharu.kotatsu.databinding.DialogProgressBinding -import java.io.File @AndroidEntryPoint class BackupDialogFragment : AlertDialogFragment() { private val viewModel by viewModels() - private val saveFileContract = registerForActivityResult( - ActivityResultContracts.CreateDocument("application/zip"), - ) { uri -> - if (uri != null) { - viewModel.saveBackup(uri) - } else { - dismiss() - } - } - override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, @@ -47,7 +35,6 @@ class BackupDialogFragment : AlertDialogFragment() { viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged) viewModel.onBackupDone.observeEvent(viewLifecycleOwner, this::onBackupDone) viewModel.onError.observeEvent(viewLifecycleOwner, this::onError) - viewModel.onBackupSaved.observeEvent(viewLifecycleOwner) { onBackupSaved() } } override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { @@ -77,14 +64,7 @@ class BackupDialogFragment : AlertDialogFragment() { } } - private fun onBackupDone(file: File) { - if (!saveFileContract.tryLaunch(file.name)) { - Toast.makeText(requireContext(), R.string.operation_not_supported, Toast.LENGTH_SHORT).show() - dismiss() - } - } - - private fun onBackupSaved() { + private fun onBackupDone(uri: Uri) { Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_SHORT).show() dismiss() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/backup/BackupService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/backup/BackupService.kt new file mode 100644 index 000000000..51cfb2914 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/backup/BackupService.kt @@ -0,0 +1,142 @@ +package org.koitharu.kotatsu.backups.ui.backup + +import android.annotation.SuppressLint +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.Uri +import androidx.annotation.CheckResult +import androidx.core.app.NotificationCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.app.ShareCompat +import androidx.core.content.ContextCompat +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.backups.data.BackupRepository +import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission +import org.koitharu.kotatsu.core.util.ext.powerManager +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.toUriOrNull +import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock +import org.koitharu.kotatsu.core.util.progress.Progress +import java.io.FileNotFoundException +import java.util.zip.ZipOutputStream +import javax.inject.Inject +import androidx.appcompat.R as appcompatR + +@AndroidEntryPoint +@SuppressLint("InlinedApi") +class BackupService : BaseBackupRestoreService() { + + override val notificationTag = TAG + + @Inject + lateinit var repository: BackupRepository + + override suspend fun IntentJobContext.processIntent(intent: Intent) { + val notification = buildNotification(Progress.INDETERMINATE) + setForeground( + FOREGROUND_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + val destination = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException() + powerManager.withPartialWakeLock(TAG) { + val progress = MutableStateFlow(Progress.INDETERMINATE) + val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) { + launch { + progress.collect { + notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(it)) + } + } + } else { + null + } + ZipOutputStream(contentResolver.openOutputStream(destination)).use { output -> + repository.createBackup(output, progress) + } + progressUpdateJob?.cancelAndJoin() + contentResolver.notifyChange(destination, null) + if (checkNotificationPermission(CHANNEL_ID)) { + notificationManager.notify(notificationTag, startId, createResultNotification(destination)) + } + } + } + + private fun IntentJobContext.buildNotification(progress: Progress): Notification { + return NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setContentTitle(getString(R.string.creating_backup)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(0) + .setSilent(true) + .setOngoing(true) + .setProgress( + progress.total.coerceAtLeast(0), + progress.progress.coerceAtLeast(0), + progress.isIndeterminate, + ) + .setContentText( + if (progress.isIndeterminate) { + getString(R.string.processing_) + } else { + getString(R.string.fraction_pattern, progress.progress, progress.total) + }, + ) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .addAction( + appcompatR.drawable.abc_ic_clear_material, + applicationContext.getString(android.R.string.cancel), + getCancelIntent(), + ).build() + } + + private fun createResultNotification(uri: Uri): Notification { + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(0) + .setSilent(true) + .setAutoCancel(true) + .setContentText(getString(R.string.backup_saved)) + .setSmallIcon(R.drawable.ic_stat_done) + val shareIntent = ShareCompat.IntentBuilder(this) + .setStream(uri) + .setType(contentResolver.getType(uri) ?: "application/zip") + .setChooserTitle(R.string.share_backup) + .createChooserIntent() + notification.setContentIntent( + PendingIntentCompat.getActivity( + applicationContext, + 0, + shareIntent, + 0, + false, + ), + ) + return notification.build() + } + + companion object { + + private const val TAG = "BACKUP" + private const val FOREGROUND_NOTIFICATION_ID = 33 + + @CheckResult + fun start(context: Context, uri: Uri): Boolean = try { + val intent = Intent(context, BackupService::class.java) + intent.putExtra(AppRouter.KEY_DATA, uri.toString()) + ContextCompat.startForegroundService(context, intent) + true + } catch (e: Exception) { + e.printStackTraceDebug() + false + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/backup/BackupViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/backup/BackupViewModel.kt new file mode 100644 index 000000000..4e0045369 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/backup/BackupViewModel.kt @@ -0,0 +1,44 @@ +package org.koitharu.kotatsu.backups.ui.backup + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import org.koitharu.kotatsu.backups.data.BackupRepository +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.ext.require +import org.koitharu.kotatsu.core.util.progress.Progress +import java.util.zip.Deflater +import java.util.zip.ZipOutputStream +import javax.inject.Inject + +@HiltViewModel +class BackupViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val repository: BackupRepository, + @ApplicationContext context: Context, +) : BaseViewModel() { + + val progress = MutableStateFlow(Progress.INDETERMINATE) + val onBackupDone = MutableEventFlow() + + private val destination = savedStateHandle.require(AppRouter.KEY_DATA) + private val contentResolver: ContentResolver = context.contentResolver + + init { + launchLoadingJob(Dispatchers.Default) { + ZipOutputStream(checkNotNull(contentResolver.openOutputStream(destination))).use { + it.setLevel(Deflater.BEST_COMPRESSION) + repository.createBackup(it, progress) + } + onBackupDone.call(destination) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupService.kt similarity index 56% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupService.kt index 06f882881..b87a6dfb8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupService.kt @@ -1,13 +1,13 @@ -package org.koitharu.kotatsu.settings.backup +package org.koitharu.kotatsu.backups.ui.periodical import android.content.Intent import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.core.backup.BackupRepository -import org.koitharu.kotatsu.core.backup.BackupZipOutput -import org.koitharu.kotatsu.core.backup.ExternalBackupStorage -import org.koitharu.kotatsu.core.backup.TelegramBackupUploader +import org.koitharu.kotatsu.backups.data.BackupRepository +import org.koitharu.kotatsu.backups.domain.BackupUtils +import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.CoroutineIntentService +import java.util.zip.ZipOutputStream import javax.inject.Inject @AndroidEntryPoint @@ -33,26 +33,18 @@ class PeriodicalBackupService : CoroutineIntentService() { if (lastBackupDate != null && lastBackupDate.time + settings.periodicalBackupFrequencyMillis > System.currentTimeMillis()) { return } - val output = BackupZipOutput.createTemp(applicationContext) + val output = BackupUtils.createTempFile(applicationContext) try { - output.use { backup -> - backup.put(repository.createIndex()) - backup.put(repository.dumpHistory()) - backup.put(repository.dumpCategories()) - backup.put(repository.dumpFavourites()) - backup.put(repository.dumpBookmarks()) - backup.put(repository.dumpSources()) - backup.put(repository.dumpSettings()) - backup.put(repository.dumpReaderGridSettings()) - backup.finish() + ZipOutputStream(output.outputStream()).use { + repository.createBackup(it, null) } - externalBackupStorage.put(output.file) + externalBackupStorage.put(output) externalBackupStorage.trim(settings.periodicalBackupMaxCount) if (settings.isBackupTelegramUploadEnabled) { - telegramBackupUploader.uploadBackup(output.file) + telegramBackupUploader.uploadBackup(output) } } finally { - output.file.delete() + output.delete() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsFragment.kt similarity index 97% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsFragment.kt index 37262769a..af8678757 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsFragment.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.settings.backup +package org.koitharu.kotatsu.backups.ui.periodical import android.content.Intent import android.net.Uri @@ -12,7 +12,6 @@ import androidx.preference.Preference import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.backup.TelegramBackupUploader import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsViewModel.kt similarity index 85% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsViewModel.kt index f557ed11d..667173a7d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/PeriodicalBackupSettingsViewModel.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.settings.backup +package org.koitharu.kotatsu.backups.ui.periodical import android.content.Context import android.net.Uri @@ -8,16 +8,14 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.backup.BackupZipOutput.Companion.DIR_BACKUPS -import org.koitharu.kotatsu.core.backup.ExternalBackupStorage -import org.koitharu.kotatsu.core.backup.TelegramBackupUploader +import org.koitharu.kotatsu.backups.domain.BackupUtils +import org.koitharu.kotatsu.backups.domain.ExternalBackupStorage import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.resolveFile -import java.io.File import java.util.Date import javax.inject.Inject @@ -60,7 +58,7 @@ class PeriodicalBackupSettingsViewModel @Inject constructor( backupsDirectory.value = if (dir != null) { dir.toUserFriendlyString() } else { - (appContext.getExternalFilesDir(DIR_BACKUPS) ?: File(appContext.filesDir, DIR_BACKUPS)).path + BackupUtils.getAppBackupDir(appContext).path } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/TelegramBackupUploader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/TelegramBackupUploader.kt similarity index 96% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/backup/TelegramBackupUploader.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/TelegramBackupUploader.kt index 6426733c2..57b434d39 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/TelegramBackupUploader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/periodical/TelegramBackupUploader.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.core.backup +package org.koitharu.kotatsu.backups.ui.periodical import android.content.Context import androidx.annotation.CheckResult @@ -33,7 +33,7 @@ class TelegramBackupUploader @Inject constructor( suspend fun uploadBackup(file: File) { val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull()) val multipartBody = MultipartBody.Builder() - .setType(MultipartBody.FORM) + .setType(MultipartBody.Companion.FORM) .addFormDataPart("chat_id", requireChatId()) .addFormDataPart("document", file.name, requestBody) .build() @@ -90,4 +90,4 @@ class TelegramBackupUploader @Inject constructor( .host("api.telegram.org") .addPathSegment("bot$botToken") .addPathSegment(method) -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntriesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/BackupEntriesAdapter.kt similarity index 65% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntriesAdapter.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/BackupEntriesAdapter.kt index 44ba6a831..cb6876c8f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntriesAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/BackupEntriesAdapter.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.settings.backup +package org.koitharu.kotatsu.backups.ui.restore import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.BaseListAdapter @@ -8,18 +8,18 @@ import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_CHECKED_CHANGED import org.koitharu.kotatsu.list.ui.adapter.ListItemType -class BackupEntriesAdapter( - clickListener: OnListItemClickListener, -) : BaseListAdapter() { +class BackupSectionsAdapter( + clickListener: OnListItemClickListener, +) : BaseListAdapter() { init { - addDelegate(ListItemType.NAV_ITEM, backupEntryAD(clickListener)) + addDelegate(ListItemType.NAV_ITEM, backupSectionAD(clickListener)) } } -private fun backupEntryAD( - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( +private fun backupSectionAD( + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) }, ) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/BackupSectionModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/BackupSectionModel.kt new file mode 100644 index 000000000..906e2bd25 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/BackupSectionModel.kt @@ -0,0 +1,44 @@ +package org.koitharu.kotatsu.backups.ui.restore + +import androidx.annotation.StringRes +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.backups.domain.BackupSection +import org.koitharu.kotatsu.list.ui.ListModelDiffCallback +import org.koitharu.kotatsu.list.ui.model.ListModel + +data class BackupSectionModel( + val section: BackupSection, + val isChecked: Boolean, + val isEnabled: Boolean, +) : ListModel { + + @get:StringRes + val titleResId: Int + get() = when (section) { + BackupSection.INDEX -> 0 // should not appear here + BackupSection.HISTORY -> R.string.history + BackupSection.CATEGORIES -> R.string.favourites_categories + BackupSection.FAVOURITES -> R.string.favourites + BackupSection.SETTINGS -> R.string.settings + BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions + BackupSection.BOOKMARKS -> R.string.bookmarks + BackupSection.SOURCES -> R.string.remote_sources + } + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is BackupSectionModel && other.section == section + } + + override fun getChangePayload(previousState: ListModel): Any? { + if (previousState !is BackupSectionModel) { + return null + } + return if (previousState.isEnabled != isEnabled) { + ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED + } else if (previousState.isChecked != isChecked) { + ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED + } else { + super.getChangePayload(previousState) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/RestoreDialogFragment.kt similarity index 92% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/RestoreDialogFragment.kt index efa040479..e6da7a4c6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/RestoreDialogFragment.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.settings.backup +package org.koitharu.kotatsu.backups.ui.restore import android.os.Bundle import android.view.LayoutInflater @@ -25,7 +25,7 @@ import java.text.SimpleDateFormat import java.util.Date @AndroidEntryPoint -class RestoreDialogFragment : AlertDialogFragment(), OnListItemClickListener, +class RestoreDialogFragment : AlertDialogFragment(), OnListItemClickListener, View.OnClickListener { private val viewModel: RestoreViewModel by viewModels() @@ -37,7 +37,7 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis override fun onViewBindingCreated(binding: DialogRestoreBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) - val adapter = BackupEntriesAdapter(this) + val adapter = BackupSectionsAdapter(this) binding.recyclerView.adapter = adapter binding.buttonCancel.setOnClickListener(this) binding.buttonRestore.setOnClickListener(this) @@ -72,11 +72,11 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis } } - override fun onItemClick(item: BackupEntryModel, view: View) { + override fun onItemClick(item: BackupSectionModel, view: View) { viewModel.onItemClick(item) } - private fun onLoadingChanged(value: Triple, Date?>) { + private fun onLoadingChanged(value: Triple, Date?>) { val (isLoading, entries, backupDate) = value val hasEntries = entries.isNotEmpty() with(requireViewBinding()) { @@ -96,7 +96,7 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis return RestoreService.start( context ?: return false, viewModel.uri ?: return false, - viewModel.getCheckedEntries(), + viewModel.getCheckedSections(), ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/RestoreService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/RestoreService.kt new file mode 100644 index 000000000..b09339547 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/RestoreService.kt @@ -0,0 +1,146 @@ +package org.koitharu.kotatsu.backups.ui.restore + +import android.annotation.SuppressLint +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.Uri +import androidx.annotation.CheckResult +import androidx.core.app.NotificationCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.app.ShareCompat +import androidx.core.content.ContextCompat +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.backups.data.BackupRepository +import org.koitharu.kotatsu.backups.domain.BackupSection +import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission +import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat +import org.koitharu.kotatsu.core.util.ext.powerManager +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.toUriOrNull +import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock +import org.koitharu.kotatsu.core.util.progress.Progress +import java.io.FileNotFoundException +import java.util.zip.ZipInputStream +import javax.inject.Inject +import androidx.appcompat.R as appcompatR + +@AndroidEntryPoint +@SuppressLint("InlinedApi") +class RestoreService : BaseBackupRestoreService() { + + override val notificationTag = TAG + + @Inject + lateinit var repository: BackupRepository + + override suspend fun IntentJobContext.processIntent(intent: Intent) { + val notification = buildNotification(Progress.INDETERMINATE) + setForeground( + FOREGROUND_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + val source = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException() + val sections = + requireNotNull(intent.getSerializableExtraCompat>(AppRouter.KEY_ENTRIES)?.toSet()) + powerManager.withPartialWakeLock(TAG) { + val progress = MutableStateFlow(Progress.INDETERMINATE) + val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) { + launch { + progress.collect { + notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(it)) + } + } + } else { + null + } + ZipInputStream(contentResolver.openInputStream(source)).use { input -> + repository.restoreBackup(input, sections, progress) + } + progressUpdateJob?.cancelAndJoin() + if (checkNotificationPermission(CHANNEL_ID)) { + notificationManager.notify(notificationTag, startId, createResultNotification(source)) + } + } + } + + private fun IntentJobContext.buildNotification(progress: Progress): Notification { + return NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setContentTitle(getString(R.string.restoring_backup)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(0) + .setSilent(true) + .setOngoing(true) + .setProgress( + progress.total.coerceAtLeast(0), + progress.progress.coerceAtLeast(0), + progress.isIndeterminate, + ) + .setContentText( + if (progress.isIndeterminate) { + getString(R.string.processing_) + } else { + getString(R.string.fraction_pattern, progress.progress, progress.total) + }, + ) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .addAction( + appcompatR.drawable.abc_ic_clear_material, + applicationContext.getString(android.R.string.cancel), + getCancelIntent(), + ).build() + } + + private fun createResultNotification(uri: Uri): Notification { + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(0) + .setSilent(true) + .setAutoCancel(true) + .setContentText(getString(R.string.backup_saved)) + .setSmallIcon(R.drawable.ic_stat_done) + val shareIntent = ShareCompat.IntentBuilder(this) + .setStream(uri) + .setType(contentResolver.getType(uri) ?: "application/zip") + .setChooserTitle(R.string.share_backup) + .createChooserIntent() + notification.setContentIntent( + PendingIntentCompat.getActivity( + applicationContext, + 0, + shareIntent, + 0, + false, + ), + ) + return notification.build() + } + + companion object { + + private const val TAG = "RESTORE" + private const val FOREGROUND_NOTIFICATION_ID = 39 + + @CheckResult + fun start(context: Context, uri: Uri, sections: Set): Boolean = try { + val intent = Intent(context, RestoreService::class.java) + intent.putExtra(AppRouter.KEY_DATA, uri.toString()) + intent.putExtra(AppRouter.KEY_ENTRIES, sections.toTypedArray()) + ContextCompat.startForegroundService(context, intent) + true + } catch (e: Exception) { + e.printStackTraceDebug() + false + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/RestoreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/RestoreViewModel.kt new file mode 100644 index 000000000..08215f7bd --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/backups/ui/restore/RestoreViewModel.kt @@ -0,0 +1,112 @@ +package org.koitharu.kotatsu.backups.ui.restore + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runInterruptible +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import org.koitharu.kotatsu.backups.data.model.BackupIndex +import org.koitharu.kotatsu.backups.domain.BackupSection +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.toUriOrNull +import java.io.FileNotFoundException +import java.io.InputStream +import java.util.Date +import java.util.EnumMap +import java.util.EnumSet +import java.util.zip.ZipInputStream +import javax.inject.Inject + +@HiltViewModel +class RestoreViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + @ApplicationContext context: Context, +) : BaseViewModel() { + + val uri = savedStateHandle.get(AppRouter.KEY_FILE)?.toUriOrNull() + private val contentResolver = context.contentResolver + + val availableEntries = MutableStateFlow>(emptyList()) + val backupDate = MutableStateFlow(null) + + init { + launchLoadingJob(Dispatchers.Default) { + loadBackupInfo() + } + } + + private suspend fun loadBackupInfo() { + val sections = runInterruptible(Dispatchers.IO) { + if (uri == null) throw FileNotFoundException() + ZipInputStream(contentResolver.openInputStream(uri)).use { stream -> + val result = EnumSet.noneOf(BackupSection::class.java) + var entry = stream.nextEntry + while (entry != null) { + val s = BackupSection.of(entry) + if (s != null) { + result.add(s) + if (s == BackupSection.INDEX) { + backupDate.value = stream.readDate() + } + } + stream.closeEntry() + entry = stream.nextEntry + } + result + } + } + availableEntries.value = BackupSection.entries.mapNotNull { entry -> + if (entry == BackupSection.INDEX || entry !in sections) { + return@mapNotNull null + } + BackupSectionModel( + section = entry, + isChecked = true, + isEnabled = true, + ) + } + } + + fun onItemClick(item: BackupSectionModel) { + val map = availableEntries.value.associateByTo(EnumMap(BackupSection::class.java)) { it.section } + map[item.section] = item.copy(isChecked = !item.isChecked) + map.validate() + availableEntries.value = map.values.sortedBy { it.section.ordinal } + } + + fun getCheckedSections(): Set = availableEntries.value + .mapNotNullTo(EnumSet.noneOf(BackupSection::class.java)) { + if (it.isChecked) it.section else null + } + + /** + * Check for inconsistent user selection + * Favorites cannot be restored without categories + */ + private fun MutableMap.validate() { + val favorites = this[BackupSection.FAVOURITES] ?: return + val categories = this[BackupSection.CATEGORIES] + if (categories?.isChecked == true) { + if (!favorites.isEnabled) { + this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = true) + } + } else { + if (favorites.isEnabled) { + this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false) + } + } + } + + private fun InputStream.readDate(): Date? = runCatching { + val index = Json.decodeFromStream>(this) + Date(index.single().createdAt) + }.onFailure { e -> + e.printStackTraceDebug() + }.getOrNull() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt index 6fa5360dc..217b3f3c6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/data/BookmarksDao.kt @@ -6,7 +6,10 @@ import androidx.room.Insert import androidx.room.Query import androidx.room.Transaction import androidx.room.Upsert +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive import org.koitharu.kotatsu.core.db.entity.MangaWithTags @Dao @@ -47,4 +50,17 @@ abstract class BookmarksDao { @Upsert abstract suspend fun upsert(bookmarks: Collection) + + fun dump(): Flow>> = flow { + val window = 4 + var offset = 0 + while (currentCoroutineContext().isActive) { + val list = findAll(offset, window) + if (list.isEmpty()) { + break + } + offset += window + list.forEach { emit(it.key to it.value) } + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt index 92e4d71ab..e703e879e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import okhttp3.OkHttpClient import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.backups.domain.BackupObserver import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.exceptions.resolve.CaptchaHandler import org.koitharu.kotatsu.core.image.AvifImageDecoder @@ -59,7 +60,6 @@ import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider -import org.koitharu.kotatsu.settings.backup.BackupObserver import org.koitharu.kotatsu.sync.domain.SyncController import org.koitharu.kotatsu.widget.WidgetUpdater import javax.inject.Provider diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt deleted file mode 100644 index 9dd16bc43..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.core.backup - -import org.json.JSONArray - -class BackupEntry( - val name: Name, - val data: JSONArray -) { - - enum class Name( - val key: String, - ) { - - INDEX("index"), - HISTORY("history"), - CATEGORIES("categories"), - FAVOURITES("favourites"), - SETTINGS("settings"), - SETTINGS_READER_GRID("reader_grid"), - BOOKMARKS("bookmarks"), - SOURCES("sources"), - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt deleted file mode 100644 index c1f30c0d1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt +++ /dev/null @@ -1,259 +0,0 @@ -package org.koitharu.kotatsu.core.backup - -import androidx.room.withTransaction -import kotlinx.coroutines.flow.FlowCollector -import org.json.JSONArray -import org.json.JSONObject -import org.koitharu.kotatsu.BuildConfig -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.progress.Progress -import org.koitharu.kotatsu.parsers.util.json.asTypedList -import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault -import org.koitharu.kotatsu.parsers.util.json.mapJSON -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.reader.data.TapGridSettings -import java.util.Date -import javax.inject.Inject - -private const val PAGE_SIZE = 10 - -class BackupRepository @Inject constructor( - private val db: MangaDatabase, - private val settings: AppSettings, - private val tapGridSettings: TapGridSettings, -) { - - suspend fun dumpHistory(): BackupEntry { - var offset = 0 - val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray()) - while (true) { - val history = db.getHistoryDao().findAll(offset = offset, limit = PAGE_SIZE) - if (history.isEmpty()) { - break - } - offset += history.size - for (item in history) { - val manga = JsonSerializer(item.manga).toJson() - val tags = JSONArray() - item.tags.forEach { tags.put(JsonSerializer(it).toJson()) } - manga.put("tags", tags) - val json = JsonSerializer(item.history).toJson() - json.put("manga", manga) - entry.data.put(json) - } - } - return entry - } - - suspend fun dumpCategories(): BackupEntry { - val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray()) - val categories = db.getFavouriteCategoriesDao().findAll() - for (item in categories) { - entry.data.put(JsonSerializer(item).toJson()) - } - return entry - } - - suspend fun dumpFavourites(): BackupEntry { - var offset = 0 - val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray()) - while (true) { - val favourites = db.getFavouritesDao().findAllRaw(offset = offset, limit = PAGE_SIZE) - if (favourites.isEmpty()) { - break - } - offset += favourites.size - for (item in favourites) { - val manga = JsonSerializer(item.manga).toJson() - val tags = JSONArray() - item.tags.forEach { tags.put(JsonSerializer(it).toJson()) } - manga.put("tags", tags) - val json = JsonSerializer(item.favourite).toJson() - json.put("manga", manga) - entry.data.put(json) - } - } - return entry - } - - suspend fun dumpBookmarks(): BackupEntry { - var offset = 0 - val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray()) - while (true) { - val bookmarks = db.getBookmarksDao().findAll(offset = offset, limit = PAGE_SIZE) - if (bookmarks.isEmpty()) { - break - } - offset += bookmarks.size - for ((m, b) in bookmarks) { - val json = JSONObject() - val manga = JsonSerializer(m.manga).toJson() - json.put("manga", manga) - val tags = JSONArray() - m.tags.forEach { tags.put(JsonSerializer(it).toJson()) } - json.put("tags", tags) - val bookmarks = JSONArray() - b.forEach { bookmarks.put(JsonSerializer(it).toJson()) } - json.put("bookmarks", bookmarks) - entry.data.put(json) - } - } - return entry - } - - fun dumpSettings(): BackupEntry { - val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray()) - val settingsDump = settings.getAllValues().toMutableMap() - settingsDump.remove(AppSettings.KEY_APP_PASSWORD) - settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD) - settingsDump.remove(AppSettings.KEY_PROXY_LOGIN) - settingsDump.remove(AppSettings.KEY_INCOGNITO_MODE) - val json = JsonSerializer(settingsDump).toJson() - entry.data.put(json) - return entry - } - - fun dumpReaderGridSettings(): BackupEntry { - val entry = BackupEntry(BackupEntry.Name.SETTINGS_READER_GRID, JSONArray()) - val settingsDump = tapGridSettings.getAllValues() - val json = JsonSerializer(settingsDump).toJson() - entry.data.put(json) - return entry - } - - suspend fun dumpSources(): BackupEntry { - val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray()) - val all = db.getSourcesDao().findAll() - for (source in all) { - val json = JsonSerializer(source).toJson() - entry.data.put(json) - } - return entry - } - - fun createIndex(): BackupEntry { - val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray()) - val json = JSONObject() - json.put("app_id", BuildConfig.APPLICATION_ID) - json.put("app_version", BuildConfig.VERSION_CODE) - json.put("created_at", System.currentTimeMillis()) - entry.data.put(json) - return entry - } - - fun getBackupDate(entry: BackupEntry?): Date? { - val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0 - return if (timestamp == 0L) null else Date(timestamp) - } - - suspend fun restoreHistory(entry: BackupEntry, outProgress: FlowCollector?): CompositeResult { - val result = CompositeResult() - val list = entry.data.asTypedList() - outProgress?.emit(Progress(progress = 0, total = list.size)) - for ((index, item) in list.withIndex()) { - val mangaJson = item.getJSONObject("manga") - val manga = JsonDeserializer(mangaJson).toMangaEntity() - val tags = mangaJson.getJSONArray("tags").mapJSON { - JsonDeserializer(it).toTagEntity() - } - val history = JsonDeserializer(item).toHistoryEntity() - result += runCatchingCancellable { - db.withTransaction { - db.getTagsDao().upsert(tags) - db.getMangaDao().upsert(manga, tags) - db.getHistoryDao().upsert(history) - } - } - outProgress?.emit(Progress(progress = index, total = list.size)) - } - return result - } - - suspend fun restoreCategories(entry: BackupEntry): CompositeResult { - val result = CompositeResult() - for (item in entry.data.asTypedList()) { - val category = JsonDeserializer(item).toFavouriteCategoryEntity() - result += runCatchingCancellable { - db.getFavouriteCategoriesDao().upsert(category) - } - } - return result - } - - suspend fun restoreFavourites(entry: BackupEntry, outProgress: FlowCollector?): CompositeResult { - val result = CompositeResult() - val list = entry.data.asTypedList() - outProgress?.emit(Progress(progress = 0, total = list.size)) - for ((index, item) in list.withIndex()) { - val mangaJson = item.getJSONObject("manga") - val manga = JsonDeserializer(mangaJson).toMangaEntity() - val tags = mangaJson.getJSONArray("tags").mapJSON { - JsonDeserializer(it).toTagEntity() - } - val favourite = JsonDeserializer(item).toFavouriteEntity() - result += runCatchingCancellable { - db.withTransaction { - db.getTagsDao().upsert(tags) - db.getMangaDao().upsert(manga, tags) - db.getFavouritesDao().upsert(favourite) - } - } - outProgress?.emit(Progress(progress = index, total = list.size)) - } - return result - } - - suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult { - val result = CompositeResult() - for (item in entry.data.asTypedList()) { - val mangaJson = item.getJSONObject("manga") - val manga = JsonDeserializer(mangaJson).toMangaEntity() - val tags = item.getJSONArray("tags").mapJSON { - JsonDeserializer(it).toTagEntity() - } - val bookmarks = item.getJSONArray("bookmarks").mapJSON { - JsonDeserializer(it).toBookmarkEntity() - } - result += runCatchingCancellable { - db.withTransaction { - db.getTagsDao().upsert(tags) - db.getMangaDao().upsert(manga, tags) - db.getBookmarksDao().upsert(bookmarks) - } - } - } - return result - } - - suspend fun restoreSources(entry: BackupEntry): CompositeResult { - val result = CompositeResult() - for (item in entry.data.asTypedList()) { - val source = JsonDeserializer(item).toMangaSourceEntity() - result += runCatchingCancellable { - db.getSourcesDao().upsert(source) - } - } - return result - } - - fun restoreSettings(entry: BackupEntry): CompositeResult { - val result = CompositeResult() - for (item in entry.data.asTypedList()) { - result += runCatchingCancellable { - settings.upsertAll(JsonDeserializer(item).toMap()) - } - } - return result - } - - fun restoreReaderGridSettings(entry: BackupEntry): CompositeResult { - val result = CompositeResult() - for (item in entry.data.asTypedList()) { - result += runCatchingCancellable { - tapGridSettings.upsertAll(JsonDeserializer(item).toMap()) - } - } - return result - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt deleted file mode 100644 index a1499afc7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.koitharu.kotatsu.core.backup - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import okhttp3.internal.closeQuietly -import okio.Closeable -import org.json.JSONArray -import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException -import java.io.File -import java.util.EnumSet -import java.util.zip.ZipException -import java.util.zip.ZipFile - -class BackupZipInput private constructor(val file: File) : Closeable { - - private val zipFile = ZipFile(file) - - suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) { - val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null - val json = zipFile.getInputStream(entry).use { - JSONArray(it.bufferedReader().readText()) - } - BackupEntry(name, json) - } - - suspend fun entries(): Set = runInterruptible(Dispatchers.IO) { - zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze -> - BackupEntry.Name.entries.find { it.key == ze.name } - } - } - - override fun close() { - zipFile.close() - } - - fun closeAndDelete() { - closeQuietly() - file.delete() - } - - companion object { - - fun from(file: File): BackupZipInput { - var res: BackupZipInput? = null - return try { - res = BackupZipInput(file) - if (res.zipFile.getEntry("index") == null) { - throw BadBackupFormatException(null) - } - res - } catch (exception: Throwable) { - res?.closeQuietly() - throw if (exception is ZipException) { - BadBackupFormatException(exception) - } else { - exception - } - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt deleted file mode 100644 index 716e77fdf..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt +++ /dev/null @@ -1,60 +0,0 @@ -package org.koitharu.kotatsu.core.backup - -import android.content.Context -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import okio.Closeable -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.zip.ZipOutput -import java.io.File -import java.text.ParseException -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.zip.Deflater - -class BackupZipOutput(val file: File) : Closeable { - - private val output = ZipOutput(file, Deflater.BEST_COMPRESSION) - - suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) { - output.put(entry.name.key, entry.data.toString(2)) - } - - suspend fun finish() = runInterruptible(Dispatchers.IO) { - output.finish() - } - - override fun close() { - output.close() - } - - companion object { - - const val DIR_BACKUPS = "backups" - private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm") - - fun generateFileName(context: Context) = buildString { - append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT)) - append('_') - append(dateTimeFormat.format(Date())) - append(".bk.zip") - } - - fun parseBackupDateTime(fileName: String): Date? = try { - dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.')) - } catch (e: ParseException) { - e.printStackTraceDebug() - null - } - - suspend fun createTemp(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { - val dir = context.run { - getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) - } - dir.mkdirs() - BackupZipOutput(File(dir, generateFileName(context))) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/CompositeResult.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/CompositeResult.kt deleted file mode 100644 index 4f1ba1ca2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/CompositeResult.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.koitharu.kotatsu.core.backup - -class CompositeResult { - - private var successCount: Int = 0 - private val errors = ArrayList() - - val size: Int - get() = successCount + errors.size - - val failures: List - get() = errors.filterNotNull() - - val isEmpty: Boolean - get() = errors.isEmpty() && successCount == 0 - - val isAllSuccess: Boolean - get() = errors.none { it != null } - - val isAllFailed: Boolean - get() = successCount == 0 && errors.isNotEmpty() - - operator fun plusAssign(result: Result<*>) { - when { - result.isSuccess -> successCount++ - result.isFailure -> errors.add(result.exceptionOrNull()) - } - } - - operator fun plusAssign(error: Throwable) { - errors.add(error) - } - - operator fun plusAssign(other: CompositeResult) { - this.successCount += other.successCount - this.errors += other.errors - } - - operator fun plus(other: CompositeResult): CompositeResult { - val result = CompositeResult() - result.successCount = this.successCount + other.successCount - result.errors.addAll(this.errors) - result.errors.addAll(other.errors) - return result - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt deleted file mode 100644 index 97d0ea712..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt +++ /dev/null @@ -1,110 +0,0 @@ -package org.koitharu.kotatsu.core.backup - -import org.json.JSONObject -import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity -import org.koitharu.kotatsu.core.db.entity.MangaEntity -import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity -import org.koitharu.kotatsu.core.db.entity.TagEntity -import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity -import org.koitharu.kotatsu.favourites.data.FavouriteEntity -import org.koitharu.kotatsu.history.data.HistoryEntity -import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.network.CloudFlareHelper -import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault -import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault -import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault -import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault -import org.koitharu.kotatsu.parsers.util.json.getStringOrNull - -class JsonDeserializer(private val json: JSONObject) { - - fun toFavouriteEntity() = FavouriteEntity( - mangaId = json.getLong("manga_id"), - categoryId = json.getLong("category_id"), - sortKey = json.getIntOrDefault("sort_key", 0), - createdAt = json.getLong("created_at"), - deletedAt = 0L, - isPinned = json.getBooleanOrDefault("pinned", false), - ) - - fun toMangaEntity() = MangaEntity( - id = json.getLong("id"), - title = json.getString("title"), - altTitles = json.getStringOrNull("alt_title"), - url = json.getString("url"), - publicUrl = json.getStringOrNull("public_url").orEmpty(), - rating = json.getDouble("rating").toFloat(), - isNsfw = json.getBooleanOrDefault("nsfw", false), - contentRating = json.getStringOrNull("content_rating"), - coverUrl = json.getString("cover_url"), - largeCoverUrl = json.getStringOrNull("large_cover_url"), - state = json.getStringOrNull("state"), - authors = json.getStringOrNull("author"), - source = json.getString("source"), - ) - - fun toTagEntity() = TagEntity( - id = json.getLong("id"), - title = json.getString("title"), - key = json.getString("key"), - source = json.getString("source"), - isPinned = json.getBooleanOrDefault("pinned", false), - ) - - fun toHistoryEntity() = HistoryEntity( - mangaId = json.getLong("manga_id"), - createdAt = json.getLong("created_at"), - updatedAt = json.getLong("updated_at"), - chapterId = json.getLong("chapter_id"), - page = json.getInt("page"), - scroll = json.getDouble("scroll").toFloat(), - percent = json.getFloatOrDefault("percent", -1f), - chaptersCount = json.getIntOrDefault("chapters", -1), - deletedAt = 0L, - ) - - fun toFavouriteCategoryEntity() = FavouriteCategoryEntity( - categoryId = json.getInt("category_id"), - createdAt = json.getLong("created_at"), - sortKey = json.getInt("sort_key"), - title = json.getString("title"), - order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name, - track = json.getBooleanOrDefault("track", true), - isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true), - deletedAt = 0L, - ) - - fun toBookmarkEntity() = BookmarkEntity( - mangaId = json.getLong("manga_id"), - pageId = json.getLong("page_id"), - chapterId = json.getLong("chapter_id"), - page = json.getInt("page"), - scroll = json.getInt("scroll"), - imageUrl = json.getString("image_url"), - createdAt = json.getLong("created_at"), - percent = json.getDouble("percent").toFloat(), - ) - - fun toMangaSourceEntity() = MangaSourceEntity( - source = json.getString("source"), - isEnabled = json.getBoolean("enabled"), - sortKey = json.getInt("sort_key"), - addedIn = json.getIntOrDefault("added_in", 0), - lastUsedAt = json.getLongOrDefault("used_at", 0L), - isPinned = json.getBooleanOrDefault("pinned", false), - cfState = json.getIntOrDefault("cf_state", CloudFlareHelper.PROTECTION_NOT_DETECTED), - ) - - fun toMap(): Map { - val map = mutableMapOf() - val keys = json.keys() - - while (keys.hasNext()) { - val key = keys.next() - val value = json.get(key) - map[key] = value - } - - return map - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt deleted file mode 100644 index e54309d1d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.koitharu.kotatsu.core.backup - -import org.json.JSONObject -import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity -import org.koitharu.kotatsu.core.db.entity.MangaEntity -import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity -import org.koitharu.kotatsu.core.db.entity.TagEntity -import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity -import org.koitharu.kotatsu.favourites.data.FavouriteEntity -import org.koitharu.kotatsu.history.data.HistoryEntity - -class JsonSerializer private constructor(private val json: JSONObject) { - - constructor(e: FavouriteEntity) : this( - JSONObject().apply { - put("manga_id", e.mangaId) - put("category_id", e.categoryId) - put("sort_key", e.sortKey) - put("created_at", e.createdAt) - put("pinned", e.isPinned) - }, - ) - - constructor(e: FavouriteCategoryEntity) : this( - JSONObject().apply { - put("category_id", e.categoryId) - put("created_at", e.createdAt) - put("sort_key", e.sortKey) - put("title", e.title) - put("order", e.order) - put("track", e.track) - put("show_in_lib", e.isVisibleInLibrary) - }, - ) - - constructor(e: HistoryEntity) : this( - JSONObject().apply { - put("manga_id", e.mangaId) - put("created_at", e.createdAt) - put("updated_at", e.updatedAt) - put("chapter_id", e.chapterId) - put("page", e.page) - put("scroll", e.scroll) - put("percent", e.percent) - put("chapters", e.chaptersCount) - }, - ) - - constructor(e: TagEntity) : this( - JSONObject().apply { - put("id", e.id) - put("title", e.title) - put("key", e.key) - put("source", e.source) - put("pinned", e.isPinned) - }, - ) - - constructor(e: MangaEntity) : this( - JSONObject().apply { - put("id", e.id) - put("title", e.title) - put("alt_title", e.altTitles) - put("url", e.url) - put("public_url", e.publicUrl) - put("rating", e.rating) - put("nsfw", e.isNsfw) - put("content_rating", e.contentRating) - put("cover_url", e.coverUrl) - put("large_cover_url", e.largeCoverUrl) - put("state", e.state) - put("author", e.authors) - put("source", e.source) - }, - ) - - constructor(e: BookmarkEntity) : this( - JSONObject().apply { - put("manga_id", e.mangaId) - put("page_id", e.pageId) - put("chapter_id", e.chapterId) - put("page", e.page) - put("scroll", e.scroll) - put("image_url", e.imageUrl) - put("created_at", e.createdAt) - put("percent", e.percent) - }, - ) - - constructor(e: MangaSourceEntity) : this( - JSONObject().apply { - put("source", e.source) - put("enabled", e.isEnabled) - put("sort_key", e.sortKey) - put("added_in", e.addedIn) - put("used_at", e.lastUsedAt) - put("pinned", e.isPinned) - put("cf_state", e.cfState) - }, - ) - - constructor(m: Map) : this( - JSONObject(m), - ) - - fun toJson(): JSONObject = json -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt index 474e4095a..7a7899c8a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt @@ -9,7 +9,10 @@ import androidx.room.Transaction import androidx.room.Upsert import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.explore.data.SourcesSortOrder @@ -90,6 +93,19 @@ abstract class MangaSourcesDao { } } + fun dumpEnabled(): Flow = flow { + val window = 10 + var offset = 0 + while (currentCoroutineContext().isActive) { + val list = findAllEnabled(offset, window) + if (list.isEmpty()) { + break + } + offset += window + list.forEach { emit(it) } + } + } + @Query("UPDATE sources SET enabled = :isEnabled WHERE source = :source") protected abstract suspend fun updateIsEnabled(source: String, isEnabled: Boolean): Int @@ -99,6 +115,9 @@ abstract class MangaSourcesDao { @RawQuery protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List + @Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY source LIMIT :limit OFFSET :offset") + protected abstract suspend fun findAllEnabled(offset: Int, limit: Int): List + private fun getQuery(enabledOnly: Boolean, order: SourcesSortOrder) = SimpleSQLiteQuery( buildString { append("SELECT * FROM sources ") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt index 85b6c8798..614634b87 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt @@ -25,6 +25,8 @@ import dagger.hilt.android.EntryPointAccessors import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity +import org.koitharu.kotatsu.backups.ui.backup.BackupDialogFragment +import org.koitharu.kotatsu.backups.ui.restore.RestoreDialogFragment import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity @@ -93,8 +95,6 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.multi.SearchActivity import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.about.AppUpdateActivity -import org.koitharu.kotatsu.settings.backup.BackupDialogFragment -import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment import org.koitharu.kotatsu.settings.override.OverrideConfigActivity import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity @@ -448,8 +448,10 @@ class AppRouter private constructor( }.show() } - fun showBackupCreateDialog() { - BackupDialogFragment().show() + fun createBackup(destination: Uri) { + BackupDialogFragment().withArgs(1) { + putParcelable(KEY_DATA, destination) + }.showDistinct() } fun showImportDialog() { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt index 31316de80..8ba3f74e7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt @@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -22,7 +23,6 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import kotlin.coroutines.CoroutineContext abstract class CoroutineIntentService : BaseService() { @@ -35,7 +35,7 @@ abstract class CoroutineIntentService : BaseService() { } private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch { - val intentJobContext = IntentJobContextImpl(startId, coroutineContext) + val intentJobContext = IntentJobContextImpl(startId, this) mutex.withLock { try { if (intent != null) { @@ -60,7 +60,7 @@ abstract class CoroutineIntentService : BaseService() { @AnyThread protected abstract fun IntentJobContext.onError(error: Throwable) - interface IntentJobContext { + interface IntentJobContext : CoroutineScope { val startId: Int @@ -71,8 +71,8 @@ abstract class CoroutineIntentService : BaseService() { protected inner class IntentJobContextImpl( override val startId: Int, - private val coroutineContext: CoroutineContext, - ) : IntentJobContext { + private val scope: CoroutineScope, + ) : IntentJobContext, CoroutineScope by scope { private var cancelReceiver: CancelReceiver? = null private var isStopped = false diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeResult.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeResult.kt new file mode 100644 index 000000000..b7e29f871 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/CompositeResult.kt @@ -0,0 +1,59 @@ +package org.koitharu.kotatsu.core.util + +class CompositeResult private constructor( + private var successCount: Int, + private val errors: List, +) { + + val size: Int + get() = successCount + errors.size + + val failures: List + get() = errors + + val isEmpty: Boolean + get() = errors.isEmpty() && successCount == 0 + + val isAllSuccess: Boolean + get() = errors.isEmpty() + + val isAllFailed: Boolean + get() = successCount == 0 && errors.isNotEmpty() + + operator fun plus(result: Result<*>): CompositeResult = CompositeResult( + successCount = successCount + if (result.isSuccess) 1 else 0, + errors = errors + listOfNotNull(result.exceptionOrNull()), + ) + + operator fun plus(other: CompositeResult): CompositeResult = CompositeResult( + successCount = successCount + other.successCount, + errors = errors + other.errors, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CompositeResult + + if (successCount != other.successCount) return false + if (errors != other.errors) return false + + return true + } + + override fun hashCode(): Int { + var result = successCount + result = 31 * result + errors.hashCode() + return result + } + + companion object { + + val EMPTY = CompositeResult(0, emptyList()) + + fun success() = CompositeResult(1, emptyList()) + + fun failure(error: Throwable) = CompositeResult(0, listOf(error)) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt index 6e5975d19..077da803e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.collection.arraySetOf import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.ellipsize +import org.koitharu.kotatsu.parsers.util.nullIfEmpty import java.util.UUID fun String.toUUIDOrNull(): UUID? = try { @@ -70,3 +71,10 @@ fun Collection.joinToStringWithLimit(context: Context, limit: Int, transf } fun String.isHttpUrl() = startsWith("https://", ignoreCase = true) || startsWith("http://", ignoreCase = true) + +fun concatStrings(context: Context, a: String?, b: String?): String? = when { + a.isNullOrEmpty() && b.isNullOrEmpty() -> null + a.isNullOrEmpty() -> b?.nullIfEmpty() + b.isNullOrEmpty() -> a.nullIfEmpty() + else -> context.getString(R.string.download_summary_pattern, a, b) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index 647ac9eaa..c4288a8ac 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -10,7 +10,10 @@ import androidx.room.Transaction import androidx.room.Upsert import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive import org.intellij.lang.annotations.Language import org.koitharu.kotatsu.core.db.MangaQueryBuilder import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES @@ -140,6 +143,19 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { @Query("SELECT manga.source AS count FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.category_id = :categoryId GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit") abstract suspend fun findPopularSources(categoryId: Long, limit: Int): List + fun dump(): Flow = flow { + val window = 10 + var offset = 0 + while (currentCoroutineContext().isActive) { + val list = findAllRaw(offset, window) + if (list.isEmpty()) { + break + } + offset += window + list.forEach { emit(it) } + } + } + /** INSERT **/ @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt index 7f2561929..68253a8c6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt @@ -8,7 +8,10 @@ import androidx.room.Query import androidx.room.RawQuery import androidx.room.Transaction import androidx.sqlite.db.SupportSQLiteQuery +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive import org.koitharu.kotatsu.core.db.MangaQueryBuilder import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.entity.MangaWithTags @@ -105,6 +108,19 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback { @Query("SELECT percent FROM history WHERE manga_id = :id AND deleted_at = 0") abstract suspend fun findProgress(id: Long): Float? + fun dump(): Flow = flow { + val window = 10 + var offset = 0 + while (currentCoroutineContext().isActive) { + val list = findAll(offset, window) + if (list.isEmpty()) { + break + } + offset += window + list.forEach { emit(it) } + } + } + @Insert(onConflict = OnConflictStrategy.IGNORE) abstract suspend fun insert(entity: HistoryEntity): Long diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index cc86b2d67..60c242085 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupService import org.koitharu.kotatsu.browser.AdListUpdateService import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.nav.router @@ -73,7 +74,6 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListenerImpl import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionMenuProvider import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter -import org.koitharu.kotatsu.settings.backup.PeriodicalBackupService import javax.inject.Inject import com.google.android.material.R as materialR diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt deleted file mode 100644 index e906b6f31..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt +++ /dev/null @@ -1,141 +0,0 @@ -package org.koitharu.kotatsu.settings.backup - -import android.app.backup.BackupAgent -import android.app.backup.BackupDataInput -import android.app.backup.BackupDataOutput -import android.app.backup.FullBackupDataOutput -import android.content.Context -import android.os.ParcelFileDescriptor -import androidx.annotation.VisibleForTesting -import kotlinx.coroutines.runBlocking -import org.koitharu.kotatsu.core.backup.BackupEntry -import org.koitharu.kotatsu.core.backup.BackupRepository -import org.koitharu.kotatsu.core.backup.BackupZipInput -import org.koitharu.kotatsu.core.backup.BackupZipOutput -import org.koitharu.kotatsu.core.db.MangaDatabase -import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.reader.data.TapGridSettings -import java.io.File -import java.io.FileDescriptor -import java.io.FileInputStream -import java.io.InputStream -import java.io.OutputStream - -class AppBackupAgent : BackupAgent() { - - override fun onBackup( - oldState: ParcelFileDescriptor?, - data: BackupDataOutput?, - newState: ParcelFileDescriptor? - ) = Unit - - override fun onRestore( - data: BackupDataInput?, - appVersionCode: Int, - newState: ParcelFileDescriptor? - ) = Unit - - override fun onFullBackup(data: FullBackupDataOutput) { - super.onFullBackup(data) - val file = - createBackupFile( - this, - BackupRepository( - MangaDatabase(context = applicationContext), - AppSettings(applicationContext), - TapGridSettings(applicationContext), - ), - ) - try { - fullBackupFile(file, data) - } finally { - file.delete() - } - } - - override fun onRestoreFile( - data: ParcelFileDescriptor, - size: Long, - destination: File?, - type: Int, - mode: Long, - mtime: Long - ) { - if (destination?.name?.endsWith(".bk.zip") == true) { - restoreBackupFile( - data.fileDescriptor, - size, - BackupRepository( - db = MangaDatabase(applicationContext), - settings = AppSettings(applicationContext), - tapGridSettings = TapGridSettings(applicationContext), - ), - ) - destination.delete() - } else { - super.onRestoreFile(data, size, destination, type, mode, mtime) - } - } - - @VisibleForTesting - fun createBackupFile(context: Context, repository: BackupRepository) = runBlocking { - BackupZipOutput.createTemp(context).use { backup -> - backup.put(repository.createIndex()) - backup.put(repository.dumpHistory()) - backup.put(repository.dumpCategories()) - backup.put(repository.dumpFavourites()) - backup.put(repository.dumpBookmarks()) - backup.put(repository.dumpSources()) - backup.put(repository.dumpSettings()) - backup.put(repository.dumpReaderGridSettings()) - backup.finish() - backup.file - } - } - - @VisibleForTesting - fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) { - val tempFile = File.createTempFile("backup_", ".tmp") - FileInputStream(fd).use { input -> - tempFile.outputStream().use { output -> - input.copyLimitedTo(output, size) - } - } - val backup = try { - BackupZipInput.from(tempFile) - } catch (e: BadBackupFormatException) { - tempFile.delete() - throw e - } - try { - runBlocking { - backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it, null) } - backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { repository.restoreCategories(it) } - backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it, null) } - backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { repository.restoreBookmarks(it) } - backup.getEntry(BackupEntry.Name.SOURCES)?.let { repository.restoreSources(it) } - backup.getEntry(BackupEntry.Name.SETTINGS)?.let { repository.restoreSettings(it) } - backup.getEntry(BackupEntry.Name.SETTINGS_READER_GRID)?.let { repository.restoreReaderGridSettings(it) } - } - } finally { - backup.close() - tempFile.delete() - } - } - - private fun InputStream.copyLimitedTo(out: OutputStream, limit: Long) { - var bytesCopied: Long = 0 - val buffer = ByteArray(DEFAULT_BUFFER_SIZE.coerceAtMost(limit.toInt())) - var bytes = read(buffer) - while (bytes >= 0) { - out.write(buffer, 0, bytes) - bytesCopied += bytes - val bytesLeft = (limit - bytesCopied).toInt() - if (bytesLeft <= 0) { - break - } - bytes = read(buffer, 0, buffer.size.coerceAtMost(bytesLeft)) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt deleted file mode 100644 index aede79ca3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt +++ /dev/null @@ -1,44 +0,0 @@ -package org.koitharu.kotatsu.settings.backup - -import androidx.annotation.StringRes -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.backup.BackupEntry -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel - -data class BackupEntryModel( - val name: BackupEntry.Name, - val isChecked: Boolean, - val isEnabled: Boolean, -) : ListModel { - - @get:StringRes - val titleResId: Int - get() = when (name) { - BackupEntry.Name.INDEX -> 0 // should not appear here - BackupEntry.Name.HISTORY -> R.string.history - BackupEntry.Name.CATEGORIES -> R.string.favourites_categories - BackupEntry.Name.FAVOURITES -> R.string.favourites - BackupEntry.Name.SETTINGS -> R.string.settings - BackupEntry.Name.SETTINGS_READER_GRID -> R.string.reader_actions - BackupEntry.Name.BOOKMARKS -> R.string.bookmarks - BackupEntry.Name.SOURCES -> R.string.remote_sources - } - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is BackupEntryModel && other.name == name - } - - override fun getChangePayload(previousState: ListModel): Any? { - if (previousState !is BackupEntryModel) { - return null - } - return if (previousState.isEnabled != isEnabled) { - ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED - } else if (previousState.isChecked != isChecked) { - ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED - } else { - super.getChangePayload(previousState) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt deleted file mode 100644 index 484b8a8ff..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt +++ /dev/null @@ -1,80 +0,0 @@ -package org.koitharu.kotatsu.settings.backup - -import android.content.ContentResolver -import android.content.Context -import android.net.Uri -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import okio.FileNotFoundException -import org.koitharu.kotatsu.core.backup.BackupRepository -import org.koitharu.kotatsu.core.backup.BackupZipOutput -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.progress.Progress -import java.io.File -import java.io.FileOutputStream -import javax.inject.Inject - -@HiltViewModel -class BackupViewModel @Inject constructor( - private val repository: BackupRepository, - @ApplicationContext context: Context, -) : BaseViewModel() { - - val progress = MutableStateFlow(Progress.INDETERMINATE) - val onBackupDone = MutableEventFlow() - val onBackupSaved = MutableEventFlow() - - private val contentResolver: ContentResolver = context.contentResolver - private var backupFile: File? = null - - init { - launchLoadingJob(Dispatchers.Default) { - val file = BackupZipOutput.createTemp(context).use { backup -> - progress.value = Progress(0, 7) - backup.put(repository.createIndex()) - - backup.put(repository.dumpHistory()) - progress.value = progress.value.inc() - - backup.put(repository.dumpCategories()) - progress.value = progress.value.inc() - - backup.put(repository.dumpFavourites()) - progress.value = progress.value.inc() - - backup.put(repository.dumpBookmarks()) - progress.value = progress.value.inc() - - backup.put(repository.dumpSources()) - progress.value = progress.value.inc() - - backup.put(repository.dumpSettings()) - progress.value = progress.value.inc() - - backup.put(repository.dumpReaderGridSettings()) - progress.value = progress.value.inc() - - backup.finish() - backup.file - } - backupFile = file - onBackupDone.call(file) - } - } - - fun saveBackup(output: Uri) { - launchLoadingJob(Dispatchers.Default) { - val file = backupFile ?: throw FileNotFoundException() - contentResolver.openFileDescriptor(output, "w")?.use { fd -> - FileOutputStream(fd.fileDescriptor).use { - it.write(file.readBytes()) - } - } - onBackupSaved.call(Unit) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreService.kt deleted file mode 100644 index debf4eb7c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreService.kt +++ /dev/null @@ -1,300 +0,0 @@ -package org.koitharu.kotatsu.settings.backup - -import android.annotation.SuppressLint -import android.app.Notification -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.net.Uri -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.PendingIntentCompat -import androidx.core.content.ContextCompat -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.runInterruptible -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ErrorReporterReceiver -import org.koitharu.kotatsu.core.backup.BackupEntry -import org.koitharu.kotatsu.core.backup.BackupRepository -import org.koitharu.kotatsu.core.backup.BackupZipInput -import org.koitharu.kotatsu.core.backup.CompositeResult -import org.koitharu.kotatsu.core.nav.AppRouter -import org.koitharu.kotatsu.core.ui.CoroutineIntentService -import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.getFileDisplayName -import org.koitharu.kotatsu.core.util.ext.powerManager -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.toUriOrNull -import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock -import org.koitharu.kotatsu.core.util.progress.Progress -import org.koitharu.kotatsu.parsers.util.mapToArray -import org.koitharu.kotatsu.parsers.util.nullIfEmpty -import java.io.File -import java.io.FileNotFoundException -import java.util.EnumSet -import javax.inject.Inject -import androidx.appcompat.R as appcompatR - -@AndroidEntryPoint -class RestoreService : CoroutineIntentService() { - - @Inject - lateinit var repository: BackupRepository - - private lateinit var notificationManager: NotificationManagerCompat - - override fun onCreate() { - super.onCreate() - notificationManager = NotificationManagerCompat.from(applicationContext) - } - - override suspend fun IntentJobContext.processIntent(intent: Intent) { - startForeground(this) - val uri = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException() - val displayName = contentResolver.getFileDisplayName(uri) - val entries = intent.getIntArrayExtra(AppRouter.KEY_ENTRIES) - ?.mapTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { BackupEntry.Name.entries[it] } - if (entries.isNullOrEmpty()) { - throw IllegalArgumentException("No entries specified") - } - powerManager.withPartialWakeLock(TAG) { - val result = runInterruptible(Dispatchers.IO) { - val tempFile = File.createTempFile("backup_", ".tmp") - (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input -> - tempFile.outputStream().use { output -> - input.copyTo(output) - } - } - BackupZipInput.from(tempFile) - }.use { backupInput -> - restoreImpl(displayName, backupInput, entries) - } - if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { - val notification = buildNotification(displayName, result) - notificationManager.notify(TAG, startId, notification) - } - } - } - - override fun IntentJobContext.onError(error: Throwable) { - if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { - val result = CompositeResult() - result += error - val notification = buildNotification(null, result) - notificationManager.notify(TAG, startId, notification) - } - } - - private suspend fun IntentJobContext.restoreImpl( - displayName: String?, - input: BackupZipInput, - entries: Set - ): CompositeResult { - val result = CompositeResult() - val showNotification = applicationContext.checkNotificationPermission(CHANNEL_ID) - var progress = Progress(0, entries.size) - - fun notify(childProgress: Progress? = null) { - if (showNotification) { - val p = childProgress?.let { progress + it } ?: progress - notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(displayName, p)) - } - } - - notify() - - if (BackupEntry.Name.HISTORY in entries) { - input.getEntry(BackupEntry.Name.HISTORY)?.let { - flow { - result += repository.restoreHistory(it, this) - }.collect { p -> - notify(p) - } - } - progress++ - } - - notify() - - if (BackupEntry.Name.CATEGORIES in entries) { - input.getEntry(BackupEntry.Name.CATEGORIES)?.let { - result += repository.restoreCategories(it) - } - progress++ - } - - notify() - - if (BackupEntry.Name.FAVOURITES in entries) { - input.getEntry(BackupEntry.Name.FAVOURITES)?.let { - flow { - result += repository.restoreFavourites(it, this) - }.collect { p -> - notify(p) - } - } - } - - notify() - - if (BackupEntry.Name.BOOKMARKS in entries) { - input.getEntry(BackupEntry.Name.BOOKMARKS)?.let { - result += repository.restoreBookmarks(it) - } - progress++ - } - - notify() - - if (BackupEntry.Name.SOURCES in entries) { - input.getEntry(BackupEntry.Name.SOURCES)?.let { - result += repository.restoreSources(it) - } - progress++ - } - - notify() - - if (BackupEntry.Name.SETTINGS in entries) { - input.getEntry(BackupEntry.Name.SETTINGS)?.let { - result += repository.restoreSettings(it) - } - progress++ - } - - notify() - - if (BackupEntry.Name.SETTINGS_READER_GRID in entries) { - input.getEntry(BackupEntry.Name.SETTINGS_READER_GRID)?.let { - result += repository.restoreReaderGridSettings(it) - } - progress++ - } - - notify() - - return result - } - - @SuppressLint("InlinedApi") - private fun startForeground(jobContext: IntentJobContext) { - val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) - .setName(getString(R.string.restoring_backup)) - .setShowBadge(true) - .setVibrationEnabled(false) - .setSound(null, null) - .setLightsEnabled(false) - .build() - notificationManager.createNotificationChannel(channel) - - val notification = jobContext.buildNotification(null, null) - - jobContext.setForeground( - FOREGROUND_NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, - ) - } - - private fun IntentJobContext.buildNotification(fileName: String?, progress: Progress?): Notification { - return NotificationCompat.Builder(applicationContext, CHANNEL_ID) - .setContentTitle(getString(R.string.restoring_backup)) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setDefaults(0) - .setSilent(true) - .setOngoing(true) - .setProgress(progress?.total ?: 0, progress?.progress ?: 0, progress == null) - .setContentText( - concatStrings( - context = this@RestoreService, - a = fileName, - b = progress?.run { getString(R.string.percent_string_pattern, percentSting()) }, - ), - ) - .setSmallIcon(android.R.drawable.stat_sys_download) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .setCategory(NotificationCompat.CATEGORY_PROGRESS) - .addAction( - appcompatR.drawable.abc_ic_clear_material, - applicationContext.getString(android.R.string.cancel), - getCancelIntent(), - ).build() - } - - private fun buildNotification(fileName: String?, result: CompositeResult): Notification { - val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setDefaults(0) - .setSilent(true) - .setAutoCancel(true) - .setSubText(fileName) - - when { - result.isEmpty -> notification.setContentTitle(getString(R.string.data_not_restored)) - .setContentText(getString(R.string.data_not_restored_text)) - .setSmallIcon(android.R.drawable.stat_notify_error) - - result.isAllSuccess -> notification.setContentTitle(getString(R.string.data_restored)) - .setContentText(getString(R.string.data_restored_success)) - .setSmallIcon(R.drawable.ic_stat_done) - - result.isAllFailed -> notification.setContentTitle(getString(R.string.error)) - .setContentText( - result.failures.map { it.getDisplayMessage(resources) }.distinct().joinToString("\n"), - ) - .setSmallIcon(android.R.drawable.stat_notify_error) - - else -> notification.setContentTitle(getString(R.string.data_restored)) - .setContentText(getString(R.string.data_restored_with_errors)) - .setSmallIcon(R.drawable.ic_stat_done) - } - result.failures.firstOrNull()?.let { error -> - ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent -> - notification.addAction( - R.drawable.ic_alert_outline, - applicationContext.getString(R.string.report), - reportIntent, - ) - } - } - notification.setContentIntent( - PendingIntentCompat.getActivity( - applicationContext, - 0, - AppRouter.homeIntent(this), - 0, - false, - ), - ) - return notification.build() - } - - private fun concatStrings(context: Context, a: String?, b: String?): String? = when { - a.isNullOrEmpty() && b.isNullOrEmpty() -> null - a.isNullOrEmpty() -> b?.nullIfEmpty() - b.isNullOrEmpty() -> a.nullIfEmpty() - else -> context.getString(R.string.download_summary_pattern, a, b) - } - - companion object { - - private const val TAG = "restore" - private const val CHANNEL_ID = "restore_backup" - private const val FOREGROUND_NOTIFICATION_ID = 39 - - fun start(context: Context, uri: Uri, entries: Set): Boolean = try { - val intent = Intent(context, RestoreService::class.java) - intent.putExtra(AppRouter.KEY_DATA, uri.toString()) - intent.putExtra(AppRouter.KEY_ENTRIES, entries.mapToArray { it.ordinal }.toIntArray()) - ContextCompat.startForegroundService(context, intent) - true - } catch (e: Exception) { - e.printStackTraceDebug() - false - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt deleted file mode 100644 index 525602579..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt +++ /dev/null @@ -1,96 +0,0 @@ -package org.koitharu.kotatsu.settings.backup - -import android.content.Context -import androidx.lifecycle.SavedStateHandle -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.runInterruptible -import org.koitharu.kotatsu.core.backup.BackupEntry -import org.koitharu.kotatsu.core.backup.BackupRepository -import org.koitharu.kotatsu.core.backup.BackupZipInput -import org.koitharu.kotatsu.core.nav.AppRouter -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.toUriOrNull -import java.io.File -import java.io.FileNotFoundException -import java.util.Date -import java.util.EnumMap -import java.util.EnumSet -import javax.inject.Inject - -@HiltViewModel -class RestoreViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, - private val repository: BackupRepository, - @ApplicationContext context: Context, -) : BaseViewModel() { - - val uri = savedStateHandle.get(AppRouter.KEY_FILE)?.toUriOrNull() - private val contentResolver = context.contentResolver - - val availableEntries = MutableStateFlow>(emptyList()) - val backupDate = MutableStateFlow(null) - - init { - launchLoadingJob(Dispatchers.Default) { - loadBackupInfo() - } - } - - private suspend fun loadBackupInfo() { - runInterruptible(Dispatchers.IO) { - val tempFile = File.createTempFile("backup_", ".tmp") - (uri?.let { contentResolver.openInputStream(it) } ?: throw FileNotFoundException()).use { input -> - tempFile.outputStream().use { output -> - input.copyTo(output) - } - } - BackupZipInput.from(tempFile) - }.use { backup -> - val entries = backup.entries() - availableEntries.value = BackupEntry.Name.entries.mapNotNull { entry -> - if (entry == BackupEntry.Name.INDEX || entry !in entries) { - return@mapNotNull null - } - BackupEntryModel( - name = entry, - isChecked = true, - isEnabled = true, - ) - } - backupDate.value = repository.getBackupDate(backup.getEntry(BackupEntry.Name.INDEX)) - } - } - - fun onItemClick(item: BackupEntryModel) { - val map = availableEntries.value.associateByTo(EnumMap(BackupEntry.Name::class.java)) { it.name } - map[item.name] = item.copy(isChecked = !item.isChecked) - map.validate() - availableEntries.value = map.values.sortedBy { it.name.ordinal } - } - - fun getCheckedEntries(): Set = availableEntries.value - .mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { - if (it.isChecked) it.name else null - } - - /** - * Check for inconsistent user selection - * Favorites cannot be restored without categories - */ - private fun MutableMap.validate() { - val favorites = this[BackupEntry.Name.FAVOURITES] ?: return - val categories = this[BackupEntry.Name.CATEGORIES] - if (categories?.isChecked == true) { - if (!favorites.isEnabled) { - this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = true) - } - } else { - if (favorites.isEnabled) { - this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false) - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt index 051fef306..decb2f3f2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt @@ -9,6 +9,7 @@ import androidx.preference.PreferenceScreen import androidx.preference.get import dagger.Reusable import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.settings.AppearanceSettingsFragment import org.koitharu.kotatsu.settings.DownloadsSettingsFragment @@ -18,7 +19,6 @@ import org.koitharu.kotatsu.settings.ReaderSettingsFragment import org.koitharu.kotatsu.settings.ServicesSettingsFragment import org.koitharu.kotatsu.settings.SuggestionsSettingsFragment import org.koitharu.kotatsu.settings.about.AboutSettingsFragment -import org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt index 584be65b9..6af9d9eaf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt @@ -15,6 +15,8 @@ import androidx.preference.TwoStatePreference import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.backups.domain.BackupUtils +import org.koitharu.kotatsu.backups.ui.backup.BackupService import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.os.AppShortcutManager @@ -49,6 +51,18 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac this, ) + private val backupCreateCall = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/zip"), + ) { uri -> + if (uri != null) { + if (!BackupService.start(requireContext(), uri)) { + Snackbar.make( + listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, + ).show() + } + } + } + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_user_data) findPreference(AppSettings.KEY_SHORTCUTS)?.isVisible = @@ -95,7 +109,11 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_BACKUP -> { - router.showBackupCreateDialog() + if (!backupCreateCall.tryLaunch(BackupUtils.generateFileName(preference.context))) { + Snackbar.make( + listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, + ).show() + } true } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5127a51ea..7b4409002 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -845,4 +845,6 @@ Block ads in browser Block advertisement in the built-in browser (beta) Collapse long description + Creating backup + Share backup diff --git a/app/src/main/res/xml/pref_user_data.xml b/app/src/main/res/xml/pref_user_data.xml index 27a7aec4f..f940423cc 100644 --- a/app/src/main/res/xml/pref_user_data.xml +++ b/app/src/main/res/xml/pref_user_data.xml @@ -48,7 +48,7 @@ android:title="@string/restore_backup" /> diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializerTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializerTest.kt deleted file mode 100644 index e0119a8c9..000000000 --- a/app/src/test/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializerTest.kt +++ /dev/null @@ -1,101 +0,0 @@ -package org.koitharu.kotatsu.core.backup - -import org.junit.Assert.assertEquals -import org.junit.Test -import org.koitharu.kotatsu.core.db.entity.MangaEntity -import org.koitharu.kotatsu.core.db.entity.TagEntity -import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity -import org.koitharu.kotatsu.favourites.data.FavouriteEntity -import org.koitharu.kotatsu.history.data.HistoryEntity -import org.koitharu.kotatsu.parsers.model.ContentRating -import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.parsers.model.MangaState -import org.koitharu.kotatsu.parsers.model.SortOrder -import java.util.concurrent.TimeUnit - -class JsonSerializerTest { - - @Test - fun toFavouriteEntity() { - val entity = FavouriteEntity( - mangaId = 40, - categoryId = 20, - sortKey = 1, - createdAt = System.currentTimeMillis(), - deletedAt = 0L, - ) - val json = JsonSerializer(entity).toJson() - val result = JsonDeserializer(json).toFavouriteEntity() - assertEquals(entity, result) - } - - @Test - fun toMangaEntity() { - val entity = MangaEntity( - id = 231, - title = "Lorem Ipsum", - altTitles = "Lorem Ispum 2", - url = "erw", - publicUrl = "hthth", - rating = 0.78f, - isNsfw = true, - contentRating = ContentRating.ADULT.name, - coverUrl = "5345", - largeCoverUrl = null, - state = MangaState.FINISHED.name, - authors = "RERE", - source = MangaParserSource.DUMMY.name, - ) - val json = JsonSerializer(entity).toJson() - val result = JsonDeserializer(json).toMangaEntity() - assertEquals(entity, result) - } - - @Test - fun toTagEntity() { - val entity = TagEntity( - id = 934023534, - title = "Adventure", - key = "adventure", - source = MangaParserSource.DUMMY.name, - ) - val json = JsonSerializer(entity).toJson() - val result = JsonDeserializer(json).toTagEntity() - assertEquals(entity, result) - } - - @Test - fun toHistoryEntity() { - val entity = HistoryEntity( - mangaId = 304135341, - createdAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(6), - updatedAt = System.currentTimeMillis(), - chapterId = 29014843034, - page = 35, - scroll = 24.0f, - percent = 0.6f, - deletedAt = 0L, - chaptersCount = 12, - ) - val json = JsonSerializer(entity).toJson() - val result = JsonDeserializer(json).toHistoryEntity() - assertEquals(entity, result) - } - - @Test - fun toFavouriteCategoryEntity() { - val entity = FavouriteCategoryEntity( - categoryId = 142, - createdAt = System.currentTimeMillis(), - sortKey = 14, - title = "Read later", - order = SortOrder.RATING.name, - track = false, - isVisibleInLibrary = true, - deletedAt = 0L, - ) - val json = JsonSerializer(entity).toJson() - val result = JsonDeserializer(json).toFavouriteCategoryEntity() - assertEquals(entity, result) - } -}