Skip to content

Commit 570db08

Browse files
authored
Allow to select what is synced (#37)
1 parent 70e4151 commit 570db08

File tree

23 files changed

+418
-35
lines changed

23 files changed

+418
-35
lines changed

app/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ android {
1818
vectorDrawables {
1919
useSupportLibrary true
2020
}
21-
versionCode 11
22-
versionName "1.10"
21+
versionCode 12
22+
versionName "1.11"
2323
}
2424

2525
ksp {

app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
android:supportsRtl="true"
2929
android:theme="@style/Theme.MyApplication"
3030
tools:targetApi="31">
31+
<activity
32+
android:name=".ui.AdvancedSyncSettingsActivity"
33+
android:exported="false" />
3134
<activity
3235
android:name=".ui.SyncErrorsActivity"
3336
android:exported="false"

app/src/main/java/com/phpbg/easysync/dav/WebDavService.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import com.phpbg.easysync.util.ParametrizedMutex
4242
import com.phpbg.easysync.util.TTLHashSet
4343
import kotlinx.coroutines.CoroutineScope
4444
import kotlinx.coroutines.Dispatchers
45+
import kotlinx.coroutines.async
46+
import kotlinx.coroutines.awaitAll
4547
import kotlinx.coroutines.flow.Flow
4648
import kotlinx.coroutines.flow.first
4749
import kotlinx.coroutines.flow.launchIn
@@ -124,6 +126,21 @@ class WebDavService(
124126
return list(path).filter { it.relativeHref.getPath() != path.getPath() }
125127
}
126128

129+
suspend fun getAllCollections(path: CollectionPath): Set<String> {
130+
val collections = mutableSetOf<String>()
131+
val children = getChildren(path)
132+
children.filter { it.isCollection }.map { child ->
133+
CoroutineScope(Dispatchers.IO).async {
134+
listOf(child.relativeHref.getPathNoLeading()) + getAllCollections(
135+
CollectionPath(
136+
child.relativeHref.getPath()
137+
)
138+
)
139+
}
140+
}.awaitAll().flatten().also { collections.addAll(it) }
141+
return collections
142+
}
143+
127144
suspend fun listCached(path: CollectionPath): List<Resource> {
128145
return listPMutex.withLock(path.getPath()) {
129146
var res = listCache.getIfPresent(path.getPath())

app/src/main/java/com/phpbg/easysync/mediastore/MediaStoreService.kt

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -65,37 +65,29 @@ class MediaStoreService(private val context: Context) {
6565
* E.g. a donwloaded image will be present both in Downloads and Images
6666
* For this reasons we merge all IDs in a Set
6767
*/
68-
suspend fun getAllIds(): Set<Long> {
68+
suspend fun getAllIds(pathExclusions: Set<String>): Set<Long> {
6969
return URIS
70-
.map { queryIds(it) }
71-
.reduce { acc, longs -> acc.union(longs) }
70+
.flatMap { getByUri(it) }
71+
.filter { !pathExclusions.contains(it.relativePath) }
72+
.map { it.id }
73+
.toSet()
7274
}
7375

7476
/**
7577
* Count all unique files to be synced
7678
*/
77-
suspend fun countAll(): Int {
78-
return getAllIds().size
79+
suspend fun countAll(pathExclusions: Set<String>): Int {
80+
return getAllIds(pathExclusions).size
7981
}
8082

81-
private suspend fun queryIds(uri: Uri): Set<Long> {
82-
val res = withContext(Dispatchers.IO) {
83-
context.contentResolver.query(
84-
uri,
85-
arrayOf(MediaStore.Files.FileColumns._ID),
86-
null,
87-
null,
88-
null
89-
)?.use { cursor ->
90-
val results = HashSet<Long>(cursor.count)
91-
val idColumnIndex = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
92-
while (cursor.moveToNext()) {
93-
results.add(cursor.getLong(idColumnIndex))
94-
}
95-
return@withContext results
96-
}
97-
}
98-
return res ?: setOf()
83+
/**
84+
* Return all unique paths syncable in mediastore
85+
*/
86+
suspend fun getAllPaths(): Set<String> {
87+
return URIS
88+
.flatMap { getByUri(it) }
89+
.map { it.relativePath }
90+
.toSet()
9991
}
10092

10193
suspend fun deleteFile(file: MediaStoreFile) {

app/src/main/java/com/phpbg/easysync/settings/Settings.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,6 @@ data class Settings(
3434
val syncOnCellular: Boolean = false,
3535
val syncOnBattery: Boolean = false,
3636
val conflictStrategy: ConflictStrategy = ConflictStrategy.IGNORE,
37-
val syncIntervalMinutes: Long = 360
37+
val syncIntervalMinutes: Long = 360,
38+
val pathExclusions: Set<String> = setOf()
3839
)

app/src/main/java/com/phpbg/easysync/settings/SettingsDataStore.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,15 @@ class SettingsDataStore constructor(context: Context) {
6969
currentSettings.copy(conflictStrategy = conflictStrategy)
7070
}
7171
}
72+
73+
suspend fun updateExclusionPath(relativePath: String, isExcluded: Boolean) {
74+
dataSource.updateData { currentSettings ->
75+
currentSettings.copy(
76+
pathExclusions = when {
77+
isExcluded -> currentSettings.pathExclusions + relativePath
78+
else -> currentSettings.pathExclusions - relativePath
79+
}
80+
)
81+
}
82+
}
7283
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2024 Samuel CHEMLA
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
package com.phpbg.easysync.ui
26+
27+
import android.content.res.Configuration
28+
import android.os.Bundle
29+
import androidx.activity.ComponentActivity
30+
import androidx.activity.compose.setContent
31+
import androidx.activity.viewModels
32+
import androidx.compose.foundation.layout.Column
33+
import androidx.compose.foundation.layout.Spacer
34+
import androidx.compose.foundation.layout.fillMaxSize
35+
import androidx.compose.foundation.layout.height
36+
import androidx.compose.foundation.layout.padding
37+
import androidx.compose.foundation.rememberScrollState
38+
import androidx.compose.foundation.verticalScroll
39+
import androidx.compose.material3.CircularProgressIndicator
40+
import androidx.compose.material3.MaterialTheme
41+
import androidx.compose.material3.Surface
42+
import androidx.compose.runtime.Composable
43+
import androidx.compose.runtime.livedata.observeAsState
44+
import androidx.compose.ui.Modifier
45+
import androidx.compose.ui.res.stringResource
46+
import androidx.compose.ui.tooling.preview.Preview
47+
import androidx.compose.ui.unit.dp
48+
import com.phpbg.easysync.R
49+
import com.phpbg.easysync.ui.components.StdText
50+
import com.phpbg.easysync.ui.components.SwitchSetting
51+
import com.phpbg.easysync.ui.components.Title
52+
import com.phpbg.easysync.ui.theme.EasySyncTheme
53+
54+
class AdvancedSyncSettingsActivity : ComponentActivity() {
55+
56+
private val viewModel: AdvancedSyncSettingsViewModel by viewModels()
57+
58+
override fun onResume() {
59+
super.onResume()
60+
viewModel.load()
61+
}
62+
63+
override fun onCreate(savedInstanceState: Bundle?) {
64+
super.onCreate(savedInstanceState)
65+
setContent {
66+
EasySyncTheme {
67+
Surface(
68+
modifier = Modifier.fillMaxSize(),
69+
color = MaterialTheme.colorScheme.background
70+
) {
71+
val uiState = viewModel.advancedSyncSettingsUiState.observeAsState()
72+
Main(
73+
uiState = uiState.value ?: AdvancedSyncSettingsUiState(paths = listOf()),
74+
toggleExclusionHandler = viewModel::toggleExclusion
75+
)
76+
}
77+
}
78+
}
79+
}
80+
}
81+
82+
@Composable
83+
private fun Main(
84+
uiState: AdvancedSyncSettingsUiState,
85+
toggleExclusionHandler: (relativePath: String, activated: Boolean) -> Unit
86+
) {
87+
Column(
88+
modifier = Modifier
89+
.fillMaxSize()
90+
.padding(16.dp)
91+
.verticalScroll(rememberScrollState())
92+
) {
93+
Title(text = stringResource(R.string.advanced_sync_settings_activity_title))
94+
Spacer(modifier = Modifier.height(16.dp))
95+
StdText(text = stringResource(R.string.advanced_sync_settings_activity_help))
96+
Spacer(modifier = Modifier.height(16.dp))
97+
98+
if (uiState.paths.isEmpty()) {
99+
CircularProgressIndicator(color = MaterialTheme.colorScheme.outline)
100+
} else {
101+
uiState.paths.forEach { syncPath ->
102+
SwitchSetting(
103+
description = syncPath.relativePath,
104+
checked = syncPath.enabled
105+
) { newState ->
106+
toggleExclusionHandler(syncPath.relativePath, newState)
107+
}
108+
}
109+
}
110+
}
111+
}
112+
113+
@Preview(name = "Light Mode", showBackground = true)
114+
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = false)
115+
@Composable
116+
private fun MainPreview() {
117+
EasySyncTheme {
118+
Main(
119+
uiState = AdvancedSyncSettingsUiState(
120+
paths = listOf(
121+
SyncPath(
122+
relativePath = "/foo",
123+
enabled = true
124+
),
125+
SyncPath(relativePath = "/bar/baz", enabled = false),
126+
SyncPath(relativePath = "/quuux", enabled = true)
127+
),
128+
),
129+
toggleExclusionHandler = { _, _ -> }
130+
)
131+
}
132+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2024 Samuel CHEMLA
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
package com.phpbg.easysync.ui
26+
27+
data class SyncPath(val relativePath: String, val enabled: Boolean)
28+
29+
data class AdvancedSyncSettingsUiState(val paths: List<SyncPath>)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2024 Samuel CHEMLA
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
package com.phpbg.easysync.ui
26+
27+
import android.app.Application
28+
import androidx.lifecycle.AndroidViewModel
29+
import androidx.lifecycle.LiveData
30+
import androidx.lifecycle.MutableLiveData
31+
import androidx.lifecycle.viewModelScope
32+
import com.phpbg.easysync.dav.CollectionPath
33+
import com.phpbg.easysync.dav.WebDavService
34+
import com.phpbg.easysync.mediastore.MediaStoreService
35+
import com.phpbg.easysync.settings.Settings
36+
import com.phpbg.easysync.settings.SettingsDataStore
37+
import kotlinx.coroutines.async
38+
import kotlinx.coroutines.awaitAll
39+
import kotlinx.coroutines.launch
40+
41+
class AdvancedSyncSettingsViewModel(application: Application) : AndroidViewModel(application) {
42+
private val mediaStoreService = MediaStoreService(getApplication())
43+
private val settingsDataStore = SettingsDataStore(getApplication())
44+
45+
private val _advancedSyncSettingsUiState = MutableLiveData<AdvancedSyncSettingsUiState>()
46+
val advancedSyncSettingsUiState: LiveData<AdvancedSyncSettingsUiState> get() = _advancedSyncSettingsUiState
47+
48+
fun load() {
49+
viewModelScope.launch {
50+
val (_paths, _settings, _davPaths) = awaitAll(
51+
async { mediaStoreService.getAllPaths() },
52+
async { settingsDataStore.getSettings() },
53+
async {
54+
val webDavService = WebDavService.create(settingsDataStore.getSettings())
55+
webDavService.getAllCollections(CollectionPath("/"))
56+
}
57+
)
58+
val paths = _paths as Set<String>
59+
val davPaths = _davPaths as Set<String>
60+
val settings = _settings as Settings
61+
val syncPaths = (paths + davPaths).toSortedSet().map {
62+
SyncPath(
63+
relativePath = it,
64+
enabled = !settings.pathExclusions.contains(it)
65+
)
66+
}
67+
_advancedSyncSettingsUiState.postValue(AdvancedSyncSettingsUiState(paths = syncPaths))
68+
}
69+
}
70+
71+
fun toggleExclusion(relativePath: String, activated: Boolean) {
72+
viewModelScope.launch {
73+
_advancedSyncSettingsUiState.value?.let { uiState ->
74+
val updatedList = uiState.paths.map {
75+
it.takeIf { it.relativePath != relativePath }
76+
?: SyncPath(relativePath = it.relativePath, enabled = activated)
77+
}
78+
_advancedSyncSettingsUiState.postValue(AdvancedSyncSettingsUiState(paths = updatedList))
79+
settingsDataStore.updateExclusionPath(relativePath, !activated)
80+
}
81+
}
82+
}
83+
}

0 commit comments

Comments
 (0)