Use streams for backups

This commit is contained in:
Koitharu
2025-06-14 12:07:52 +03:00
parent 4ef6908e82
commit b9d4c070eb
66 changed files with 1707 additions and 1583 deletions

View File

@@ -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'
]

View File

@@ -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

View File

@@ -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<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"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"
}
}

View File

@@ -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<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"chapters": [],
"source": "READMANGA_RU"
}
}

View File

@@ -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<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"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"
}
}

View File

@@ -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<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"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"
}
}

View File

@@ -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"
}
}

View File

@@ -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<br>Начало истории читайте в \"Воспоминаниях Эманон\". \n<div class=\"clearfix\"></div>",
"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"
}
}

View File

@@ -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)
}
}
}
private class MangaSourceAdapter : JsonAdapter<MangaSource>() {
@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<Instant>() {
@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)
}
}
}

View File

@@ -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

View File

@@ -48,7 +48,7 @@
<application
android:name="org.koitharu.kotatsu.KotatsuApp"
android:allowBackup="true"
android:backupAgent="org.koitharu.kotatsu.settings.backup.AppBackupAgent"
android:backupAgent="org.koitharu.kotatsu.backups.domain.AppBackupAgent"
android:dataExtractionRules="@xml/backup_rules"
android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled"
android:fullBackupContent="@xml/backup_content"
@@ -297,7 +297,7 @@
android:foregroundServiceType="dataSync"
android:label="@string/local_manga_processing" />
<service
android:name="org.koitharu.kotatsu.settings.backup.PeriodicalBackupService"
android:name="org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupService"
android:foregroundServiceType="dataSync"
android:label="@string/periodic_backups" />
<service
@@ -308,9 +308,13 @@
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
android:label="@string/local_manga_processing" />
<service
android:name="org.koitharu.kotatsu.settings.backup.RestoreService"
android:name="org.koitharu.kotatsu.backups.ui.backup.BackupService"
android:foregroundServiceType="dataSync"
android:label="@string/restore_backup" />
android:label="@string/creating_backup" />
<service
android:name="org.koitharu.kotatsu.backups.ui.restore.RestoreService"
android:foregroundServiceType="dataSync"
android:label="@string/restoring_backup" />
<service
android:name="org.koitharu.kotatsu.local.ui.ImportService"
android:foregroundServiceType="dataSync"

View File

@@ -0,0 +1,250 @@
package org.koitharu.kotatsu.backups.data
import androidx.collection.ArrayMap
import androidx.room.withTransaction
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.SerializationStrategy
import kotlinx.serialization.json.DecodeSequenceMode
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeToSequence
import kotlinx.serialization.json.encodeToStream
import kotlinx.serialization.serializer
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.backups.data.model.BackupIndex
import org.koitharu.kotatsu.backups.data.model.BookmarkBackup
import org.koitharu.kotatsu.backups.data.model.CategoryBackup
import org.koitharu.kotatsu.backups.data.model.FavouriteBackup
import org.koitharu.kotatsu.backups.data.model.HistoryBackup
import org.koitharu.kotatsu.backups.data.model.MangaBackup
import org.koitharu.kotatsu.backups.data.model.SourceBackup
import org.koitharu.kotatsu.backups.domain.BackupSection
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.CompositeResult
import org.koitharu.kotatsu.core.util.progress.Progress
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.data.TapGridSettings
import java.io.InputStream
import java.io.OutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
import javax.inject.Inject
class BackupRepository @Inject constructor(
private val database: MangaDatabase,
private val settings: AppSettings,
private val tapGridSettings: TapGridSettings,
) {
suspend fun createBackup(
output: ZipOutputStream,
progress: FlowCollector<Progress>?
) {
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<BackupSection>,
progress: FlowCollector<Progress>?
): 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<HistoryBackup>(serializer()).restoreToDb {
upsertManga(it.manga)
getHistoryDao().upsert(it.toEntity())
}
BackupSection.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
getFavouriteCategoriesDao().upsert(it.toEntity())
}
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(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<BookmarkBackup>(serializer()).restoreToDb {
upsertManga(it.manga)
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
}
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(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 <T> ZipOutputStream.writeJsonArray(
section: BackupSection,
data: Flow<T>,
serializer: SerializationStrategy<T>,
) {
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 <T> InputStream.readJsonArray(
serializer: DeserializationStrategy<T>,
): Sequence<T> = Json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
private fun InputStream.readMap(): Map<String, Any?> {
val jo = JSONArray(readString()).getJSONObject(0)
val map = ArrayMap<String, Any?>(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 <T> Sequence<T>.restoreToDb(crossinline block: suspend MangaDatabase.(T) -> Unit): CompositeResult {
return fold(CompositeResult.EMPTY) { result, item ->
result + runCatchingCancellable {
database.withTransaction {
database.block(item)
}
}
}
}
}

View File

@@ -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(),
)
}

View File

@@ -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<TagBackup>,
@SerialName("bookmarks") val bookmarks: List<Bookmark>,
) {
@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<BookmarkEntity>) : 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,
)
},
)
}

View File

@@ -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,
)
}

View File

@@ -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,
)
}

View File

@@ -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,
)
}

View File

@@ -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<TagBackup> = 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,
)
}

View File

@@ -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,
)
}

View File

@@ -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,
)
}

View File

@@ -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)
}
}
}
}

View File

@@ -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<BackupFile> {
) : Comparable<BackupFile> {
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
}

View File

@@ -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)

View File

@@ -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 }
}
}
}

View File

@@ -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")
}
}

View File

@@ -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 ->

View File

@@ -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"
}
}

View File

@@ -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<DialogProgressBinding>() {
private val viewModel by viewModels<BackupViewModel>()
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<DialogProgressBinding>() {
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<DialogProgressBinding>() {
}
}
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()
}

View File

@@ -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
}
}
}

View File

@@ -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<Uri>()
private val destination = savedStateHandle.require<Uri>(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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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<BackupEntryModel>,
) : BaseListAdapter<BackupEntryModel>() {
class BackupSectionsAdapter(
clickListener: OnListItemClickListener<BackupSectionModel>,
) : BaseListAdapter<BackupSectionModel>() {
init {
addDelegate(ListItemType.NAV_ITEM, backupEntryAD(clickListener))
addDelegate(ListItemType.NAV_ITEM, backupSectionAD(clickListener))
}
}
private fun backupEntryAD(
clickListener: OnListItemClickListener<BackupEntryModel>,
) = adapterDelegateViewBinding<BackupEntryModel, BackupEntryModel, ItemCheckableMultipleBinding>(
private fun backupSectionAD(
clickListener: OnListItemClickListener<BackupSectionModel>,
) = adapterDelegateViewBinding<BackupSectionModel, BackupSectionModel, ItemCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {

View File

@@ -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)
}
}
}

View File

@@ -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<DialogRestoreBinding>(), OnListItemClickListener<BackupEntryModel>,
class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnListItemClickListener<BackupSectionModel>,
View.OnClickListener {
private val viewModel: RestoreViewModel by viewModels()
@@ -37,7 +37,7 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), 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<DialogRestoreBinding>(), OnLis
}
}
override fun onItemClick(item: BackupEntryModel, view: View) {
override fun onItemClick(item: BackupSectionModel, view: View) {
viewModel.onItemClick(item)
}
private fun onLoadingChanged(value: Triple<Boolean, List<BackupEntryModel>, Date?>) {
private fun onLoadingChanged(value: Triple<Boolean, List<BackupSectionModel>, Date?>) {
val (isLoading, entries, backupDate) = value
val hasEntries = entries.isNotEmpty()
with(requireViewBinding()) {
@@ -96,7 +96,7 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
return RestoreService.start(
context ?: return false,
viewModel.uri ?: return false,
viewModel.getCheckedEntries(),
viewModel.getCheckedSections(),
)
}

View File

@@ -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<Array<BackupSection>>(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<BackupSection>): 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
}
}
}

View File

@@ -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<String>(AppRouter.KEY_FILE)?.toUriOrNull()
private val contentResolver = context.contentResolver
val availableEntries = MutableStateFlow<List<BackupSectionModel>>(emptyList())
val backupDate = MutableStateFlow<Date?>(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<BackupSection> = 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<BackupSection, BackupSectionModel>.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<List<BackupIndex>>(this)
Date(index.single().createdAt)
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrNull()
}

View File

@@ -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<BookmarkEntity>)
fun dump(): Flow<Pair<MangaWithTags, List<BookmarkEntity>>> = 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) }
}
}
}

View File

@@ -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

View File

@@ -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"),
}
}

View File

@@ -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<Progress>?): CompositeResult {
val result = CompositeResult()
val list = entry.data.asTypedList<JSONObject>()
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<JSONObject>()) {
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
result += runCatchingCancellable {
db.getFavouriteCategoriesDao().upsert(category)
}
}
return result
}
suspend fun restoreFavourites(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
val result = CompositeResult()
val list = entry.data.asTypedList<JSONObject>()
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<JSONObject>()) {
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<JSONObject>()) {
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<JSONObject>()) {
result += runCatchingCancellable {
settings.upsertAll(JsonDeserializer(item).toMap())
}
}
return result
}
fun restoreReaderGridSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) {
result += runCatchingCancellable {
tapGridSettings.upsertAll(JsonDeserializer(item).toMap())
}
}
return result
}
}

View File

@@ -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<BackupEntry.Name> = 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
}
}
}
}
}

View File

@@ -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)))
}
}
}

View File

@@ -1,46 +0,0 @@
package org.koitharu.kotatsu.core.backup
class CompositeResult {
private var successCount: Int = 0
private val errors = ArrayList<Throwable?>()
val size: Int
get() = successCount + errors.size
val failures: List<Throwable>
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
}
}

View File

@@ -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<String, Any?> {
val map = mutableMapOf<String, Any?>()
val keys = json.keys()
while (keys.hasNext()) {
val key = keys.next()
val value = json.get(key)
map[key] = value
}
return map
}
}

View File

@@ -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<String, *>) : this(
JSONObject(m),
)
fun toJson(): JSONObject = json
}

View File

@@ -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<MangaSourceEntity> = 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<MangaSourceEntity>
@Query("SELECT * FROM sources WHERE enabled = 1 ORDER BY source LIMIT :limit OFFSET :offset")
protected abstract suspend fun findAllEnabled(offset: Int, limit: Int): List<MangaSourceEntity>
private fun getQuery(enabledOnly: Boolean, order: SourcesSortOrder) = SimpleSQLiteQuery(
buildString {
append("SELECT * FROM sources ")

View File

@@ -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() {

View File

@@ -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

View File

@@ -0,0 +1,59 @@
package org.koitharu.kotatsu.core.util
class CompositeResult private constructor(
private var successCount: Int,
private val errors: List<Throwable>,
) {
val size: Int
get() = successCount + errors.size
val failures: List<Throwable>
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))
}
}

View File

@@ -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 <T> Collection<T>.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)
}

View File

@@ -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<String>
fun dump(): Flow<FavouriteManga> = 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)

View File

@@ -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<HistoryWithManga> = 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

View File

@@ -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

View File

@@ -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))
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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<File>()
val onBackupSaved = MutableEventFlow<Unit>()
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)
}
}
}

View File

@@ -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<BackupEntry.Name>
): 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<BackupEntry.Name>): 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
}
}
}

View File

@@ -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<String>(AppRouter.KEY_FILE)?.toUriOrNull()
private val contentResolver = context.contentResolver
val availableEntries = MutableStateFlow<List<BackupEntryModel>>(emptyList())
val backupDate = MutableStateFlow<Date?>(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<BackupEntry.Name> = 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<BackupEntry.Name, BackupEntryModel>.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)
}
}
}
}

View File

@@ -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

View File

@@ -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<Preference>(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
}

View File

@@ -845,4 +845,6 @@
<string name="adblock">Block ads in browser</string>
<string name="adblock_summary">Block advertisement in the built-in browser (beta)</string>
<string name="collapse_long_description">Collapse long description</string>
<string name="creating_backup">Creating backup</string>
<string name="share_backup">Share backup</string>
</resources>

View File

@@ -48,7 +48,7 @@
android:title="@string/restore_backup" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment"
android:fragment="org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment"
android:key="backup_periodic"
android:persistent="false"
android:title="@string/periodic_backups" />

View File

@@ -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)
}
}