Use streams for backups
This commit is contained in:
@@ -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'
|
||||
]
|
||||
|
||||
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 ->
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user