Skip to content

Commit 559cd46

Browse files
committed
Add support for importing and exporting subscriptions
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
1 parent 1537cfd commit 559cd46

6 files changed

Lines changed: 205 additions & 4 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package at.bitfire.icsdroid.db.entity
2+
3+
import androidx.core.net.toUri
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Test
6+
7+
class TestSubscription {
8+
@Test
9+
fun test_json_conversion() {
10+
val subscription = Subscription(
11+
url = "".toUri(),
12+
displayName = "abc"
13+
)
14+
val json = subscription.toJSON()
15+
assertEquals(subscription, Subscription(json))
16+
}
17+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package at.bitfire.icsdroid
2+
3+
import org.json.JSONObject
4+
5+
/**
6+
* Returns the value mapped by name if it exists, coercing it if necessary, or `null` if no such mapping exists.
7+
*/
8+
fun JSONObject.getStringOrNull(name: String): String? {
9+
if (!has(name)) return null
10+
return getString(name)
11+
}

app/src/main/java/at/bitfire/icsdroid/db/entity/Subscription.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ package at.bitfire.icsdroid.db.entity
77
import android.net.Uri
88
import android.provider.CalendarContract.Calendars
99
import androidx.core.content.contentValuesOf
10+
import androidx.core.net.toUri
1011
import androidx.room.ColumnInfo
1112
import androidx.room.Entity
1213
import androidx.room.PrimaryKey
14+
import at.bitfire.icsdroid.getStringOrNull
15+
import org.json.JSONObject
1316

1417
/**
1518
* Represents the storage of a subscription the user has made.
@@ -49,6 +52,19 @@ data class Subscription(
4952
/** The color that represents the subscription. */
5053
val color: Int? = null
5154
) {
55+
constructor(json: JSONObject): this(
56+
url = json.getString("url").toUri(),
57+
eTag = json.getStringOrNull("eTag"),
58+
displayName = json.getString("displayName"),
59+
lastModified = json.getStringOrNull("lastModified")?.toLongOrNull(),
60+
lastSync = json.getStringOrNull("lastSync")?.toLongOrNull(),
61+
errorMessage = json.getStringOrNull("errorMessage"),
62+
ignoreEmbeddedAlerts = json.getStringOrNull("ignoreEmbeddedAlerts").toBoolean(),
63+
defaultAlarmMinutes = json.getStringOrNull("defaultAlarmMinutes")?.toLongOrNull(),
64+
defaultAllDayAlarmMinutes = json.getStringOrNull("defaultAllDayAlarmMinutes")?.toLongOrNull(),
65+
ignoreDescription = json.getStringOrNull("ignoreDescription").toBoolean(),
66+
color = json.getStringOrNull("color")?.toIntOrNull(),
67+
)
5268

5369
/**
5470
* Converts this subscription's properties to [android.content.ContentValues] that can be
@@ -62,4 +78,18 @@ data class Subscription(
6278
Calendars.SYNC_EVENTS to 1
6379
)
6480

81+
fun toJSON(): JSONObject = JSONObject().apply {
82+
put("url", url)
83+
put("eTag", eTag)
84+
put("displayName", displayName)
85+
put("lastModified", lastModified)
86+
put("lastSync", lastSync)
87+
put("errorMessage", errorMessage)
88+
put("ignoreEmbeddedAlerts", ignoreEmbeddedAlerts)
89+
put("defaultAlarmMinutes", defaultAlarmMinutes)
90+
put("defaultAllDayAlarmMinutes", defaultAllDayAlarmMinutes)
91+
put("ignoreDescription", ignoreDescription)
92+
put("color", color)
93+
}
94+
6595
}

app/src/main/java/at/bitfire/icsdroid/model/SubscriptionsModel.kt

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import android.content.SharedPreferences
88
import android.net.Uri
99
import android.os.Build
1010
import android.os.PowerManager
11+
import android.util.Log
1112
import android.widget.Toast
1213
import androidx.compose.runtime.getValue
1314
import androidx.compose.runtime.mutableStateOf
@@ -19,17 +20,23 @@ import androidx.lifecycle.viewModelScope
1920
import androidx.work.WorkInfo
2021
import at.bitfire.icsdroid.AppAccount
2122
import at.bitfire.icsdroid.BuildConfig
23+
import at.bitfire.icsdroid.Constants.TAG
2224
import at.bitfire.icsdroid.PermissionUtils
2325
import at.bitfire.icsdroid.R
2426
import at.bitfire.icsdroid.Settings
2527
import at.bitfire.icsdroid.SyncWorker
2628
import at.bitfire.icsdroid.dataStore
2729
import at.bitfire.icsdroid.db.AppDatabase
30+
import at.bitfire.icsdroid.db.entity.Subscription
2831
import kotlinx.coroutines.Dispatchers
2932
import kotlinx.coroutines.flow.SharingStarted
3033
import kotlinx.coroutines.flow.map
3134
import kotlinx.coroutines.flow.stateIn
3235
import kotlinx.coroutines.launch
36+
import kotlinx.coroutines.withContext
37+
import org.json.JSONArray
38+
import java.io.FileInputStream
39+
import java.io.FileOutputStream
3340

3441
class SubscriptionsModel(application: Application): AndroidViewModel(application) {
3542

@@ -50,9 +57,10 @@ class SubscriptionsModel(application: Application): AndroidViewModel(application
5057
workInfos.any { it.state == WorkInfo.State.RUNNING }
5158
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
5259

60+
val subscriptionsDao = AppDatabase.getInstance(application).subscriptionsDao()
61+
5362
/** LiveData watching the subscriptions */
54-
val subscriptions = AppDatabase.getInstance(application)
55-
.subscriptionsDao()
63+
val subscriptions = subscriptionsDao
5664
.getAllFlow()
5765
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
5866

@@ -161,4 +169,74 @@ class SubscriptionsModel(application: Application): AndroidViewModel(application
161169

162170
getApplication<Application>().startActivity(intent)
163171
}
172+
173+
fun onBackupExportRequested(uri: Uri) {
174+
val context: Context = getApplication()
175+
viewModelScope.launch(Dispatchers.IO) {
176+
val toast = withContext(Dispatchers.Main) {
177+
Toast.makeText(context, R.string.backup_exporting, Toast.LENGTH_LONG)
178+
.also { it.show() }
179+
}
180+
181+
val subscriptions = subscriptions.value
182+
Log.i(TAG, "Exporting ${subscriptions.size} subscriptions...")
183+
184+
val json = JSONArray().apply {
185+
for (subscription in subscriptions) {
186+
put(subscription.toJSON())
187+
}
188+
}
189+
context.contentResolver.openFileDescriptor(uri, "w")?.use { fd ->
190+
FileOutputStream(fd.fileDescriptor).bufferedWriter().use { output ->
191+
output.write(json.toString())
192+
}
193+
}
194+
195+
withContext(Dispatchers.Main) {
196+
toast.cancel()
197+
Toast.makeText(context, R.string.backup_exported, Toast.LENGTH_SHORT).show()
198+
}
199+
}
200+
}
201+
202+
fun onBackupImportRequested(uri: Uri) {
203+
val context: Context = getApplication()
204+
viewModelScope.launch(Dispatchers.IO) {
205+
val toast = withContext(Dispatchers.Main) {
206+
Toast.makeText(context, R.string.backup_importing, Toast.LENGTH_LONG)
207+
.also { it.show() }
208+
}
209+
210+
val jsonString = context.contentResolver.openFileDescriptor(uri, "r")?.use { fd ->
211+
FileInputStream(fd.fileDescriptor).bufferedReader().use { input ->
212+
input.readText()
213+
}
214+
}
215+
val jsonArray = JSONArray(jsonString)
216+
val newSubscriptions = (0 until jsonArray.length())
217+
.map { jsonArray.getJSONObject(it) }
218+
.map { Subscription(it) }
219+
Log.i(TAG, "Importing ${newSubscriptions.size} subscriptions...")
220+
221+
val oldSubscriptions = subscriptions.value
222+
223+
for (subscription in newSubscriptions) {
224+
val existingSubscription = oldSubscriptions.find { it.url == subscription.url }
225+
if (existingSubscription != null) {
226+
Log.w(TAG, "Overriding existing subscription (${existingSubscription.id}): ${existingSubscription.url}")
227+
subscriptionsDao.delete(existingSubscription)
228+
}
229+
subscriptionsDao.add(subscription)
230+
}
231+
232+
withContext(Dispatchers.Main) {
233+
toast.cancel()
234+
Toast.makeText(
235+
context,
236+
context.getString(R.string.backup_imported, newSubscriptions.size),
237+
Toast.LENGTH_SHORT
238+
).show()
239+
}
240+
}
241+
}
164242
}

app/src/main/java/at/bitfire/icsdroid/ui/screen/CalendarListScreen.kt

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package at.bitfire.icsdroid.ui.screen
22

33
import android.net.Uri
44
import android.os.Build
5+
import androidx.activity.compose.rememberLauncherForActivityResult
6+
import androidx.activity.result.contract.ActivityResultContracts
57
import androidx.compose.animation.AnimatedVisibility
68
import androidx.compose.foundation.layout.PaddingValues
79
import androidx.compose.foundation.layout.Row
@@ -46,6 +48,7 @@ import at.bitfire.icsdroid.model.SubscriptionsModel
4648
import at.bitfire.icsdroid.ui.partials.ActionCard
4749
import at.bitfire.icsdroid.ui.partials.CalendarListItem
4850
import at.bitfire.icsdroid.ui.partials.ExtendedTopAppBar
51+
import at.bitfire.icsdroid.ui.partials.GenericAlertDialog
4952
import at.bitfire.icsdroid.ui.partials.SyncIntervalDialog
5053
import at.bitfire.icsdroid.ui.views.CalendarListActivity
5154

@@ -64,6 +67,19 @@ fun CalendarListScreen(
6467
val forceDarkMode by model.forceDarkMode.collectAsState()
6568
val syncInterval by model.syncInterval.collectAsState()
6669

70+
val createFileResultLauncher = rememberLauncherForActivityResult(
71+
ActivityResultContracts.CreateDocument("application/json")
72+
) { result ->
73+
result ?: return@rememberLauncherForActivityResult
74+
model.onBackupExportRequested(result)
75+
}
76+
val loadFileResultLauncher = rememberLauncherForActivityResult(
77+
ActivityResultContracts.OpenDocument()
78+
) { result ->
79+
result ?: return@rememberLauncherForActivityResult
80+
model.onBackupImportRequested(result)
81+
}
82+
6783
CalendarListScreen(
6884
isRefreshing = isRefreshing,
6985
subscriptions = subscriptions,
@@ -80,6 +96,8 @@ fun CalendarListScreen(
8096
onSyncIntervalChange = model::onSyncIntervalChange,
8197
onAboutRequested = onAboutRequested,
8298
onToggleDarkMode = model::onToggleDarkMode,
99+
onBackupExportRequested = { createFileResultLauncher.launch("icsx5-backup.json") },
100+
onBackupImportRequested = { loadFileResultLauncher.launch(arrayOf("application/json")) },
83101
onItemSelected = onItemSelected
84102
)
85103
}
@@ -102,6 +120,8 @@ fun CalendarListScreen(
102120
onSyncIntervalChange: (Long) -> Unit = {},
103121
onToggleDarkMode: (forceDarkMode: Boolean) -> Unit = {},
104122
onAboutRequested: () -> Unit = {},
123+
onBackupExportRequested: () -> Unit = {},
124+
onBackupImportRequested: () -> Unit = {},
105125
onItemSelected: (Subscription) -> Unit = {}
106126
) {
107127
Scaffold(
@@ -122,12 +142,15 @@ fun CalendarListScreen(
122142
},
123143
actions = {
124144
ActionOverflowMenu(
145+
subscriptionsCount = subscriptions.size,
125146
forceDarkMode = forceDarkMode,
126147
syncInterval = syncInterval,
127148
onSyncIntervalChange = onSyncIntervalChange,
128149
onToggleDarkMode = onToggleDarkMode,
129150
onAboutRequested = onAboutRequested,
130-
onRefreshRequested = onForceRefreshRequested
151+
onRefreshRequested = onForceRefreshRequested,
152+
onBackupExportRequested = onBackupExportRequested,
153+
onBackupImportRequested = onBackupImportRequested
131154
)
132155
}
133156
)
@@ -269,12 +292,15 @@ private fun CalendarListContent(
269292

270293
@Composable
271294
fun ActionOverflowMenu(
295+
subscriptionsCount: Int,
272296
forceDarkMode: Boolean,
273297
syncInterval: Long,
274298
onSyncIntervalChange: (Long) -> Unit = {},
275299
onToggleDarkMode: (forceDarkMode: Boolean) -> Unit = {},
276300
onAboutRequested: () -> Unit = {},
277-
onRefreshRequested: () -> Unit = {}
301+
onRefreshRequested: () -> Unit = {},
302+
onBackupExportRequested: () -> Unit = {},
303+
onBackupImportRequested: () -> Unit = {}
278304
) {
279305
val context = LocalContext.current
280306

@@ -292,6 +318,16 @@ fun ActionOverflowMenu(
292318
onDismiss = { showSyncIntervalDialog = false }
293319
)
294320

321+
var showImportWarningDialog by rememberSaveable { mutableStateOf(false) }
322+
if (showImportWarningDialog)
323+
GenericAlertDialog(
324+
title = stringResource(R.string.backup_warning_title),
325+
confirmButton = stringResource(android.R.string.ok) to onBackupImportRequested,
326+
dismissButton = stringResource(android.R.string.cancel) to { showImportWarningDialog = false },
327+
onDismissRequest = { showImportWarningDialog = false },
328+
content = { Text(stringResource(R.string.backup_warning_message)) }
329+
)
330+
295331
DropdownMenu(
296332
expanded = showMenu,
297333
onDismissRequest = { showMenu = false }
@@ -325,6 +361,25 @@ fun ActionOverflowMenu(
325361
onToggleDarkMode(!forceDarkMode)
326362
}
327363
)
364+
DropdownMenuItem(
365+
enabled = subscriptionsCount > 0,
366+
text = { Text(stringResource(R.string.calendar_list_backup_export)) },
367+
onClick = {
368+
showMenu = false
369+
onBackupExportRequested()
370+
}
371+
)
372+
DropdownMenuItem(
373+
text = { Text(stringResource(R.string.calendar_list_backup_import)) },
374+
onClick = {
375+
showMenu = false
376+
// If there's already a subscription, show a warning before running import
377+
if (subscriptionsCount > 0)
378+
showImportWarningDialog = true
379+
else
380+
onBackupImportRequested()
381+
}
382+
)
328383
DropdownMenuItem(
329384
text = { Text(stringResource(R.string.calendar_list_privacy_policy)) },
330385
onClick = {

app/src/main/res/values/strings.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
<string name="calendar_list_not_synced_yet">not synchronized yet</string>
2525
<string name="calendar_list_set_sync_interval">Set sync. interval</string>
2626
<string name="calendar_list_force_sync">Force sync</string>
27+
<string name="calendar_list_backup">Backup</string>
28+
<string name="calendar_list_backup_export">Export Backup</string>
29+
<string name="calendar_list_backup_import">Import Backup</string>
2730
<string name="calendar_list_battery_whitelist_title">Battery optimization</string>
2831
<string name="calendar_list_battery_whitelist_text">Battery optimizations may prevent sync intervals shorter than a day. Set %s to \"Not optimized\".</string>
2932
<string name="calendar_list_battery_whitelist_open_settings">Open settings</string>
@@ -139,4 +142,11 @@ along with this program. If not, see <a href="https://www.gnu.org/licenses/">ht
139142
<string name="donate_now">Show donation page</string>
140143
<string name="donate_later">Maybe later</string>
141144

145+
<string name="backup_warning_title">Attention!</string>
146+
<string name="backup_warning_message">The import process may override any subscriptions you have currently added. This cannot be undone. Do you want to continue?</string>
147+
<string name="backup_exporting">Exporting…</string>
148+
<string name="backup_exported">Backup exported!</string>
149+
<string name="backup_importing">Importing…</string>
150+
<string name="backup_imported">%d subscriptions imported</string>
151+
142152
</resources>

0 commit comments

Comments
 (0)