From 7bf48b6f21bd36af05c66d7bf72a9fb7908f8f5b Mon Sep 17 00:00:00 2001 From: Robert Huselius Date: Fri, 16 Jun 2023 06:03:02 +0200 Subject: [PATCH] SFTP sync added! --- app/build.gradle.kts | 3 + .../main/java/us/huseli/retain/Constants.kt | 16 +- app/src/main/java/us/huseli/retain/Enums.kt | 5 + .../us/huseli/retain/compose/HomeScreen.kt | 18 +- .../huseli/retain/compose/RetainScaffold.kt | 10 +- .../compose/settings/NextCloudSection.kt | 230 ++++----- .../retain/compose/settings/SFTPSection.kt | 210 ++++++++ .../retain/compose/settings/SettingsScreen.kt | 2 +- .../compose/settings/SyncBackendSection.kt | 80 +++ .../huseli/retain/data/NextCloudRepository.kt | 87 ---- .../us/huseli/retain/data/NoteRepository.kt | 1 - .../retain/data/SyncBackendRepository.kt | 103 ++++ .../java/us/huseli/retain/di/IoScopeModule.kt | 2 +- .../retain/nextcloud/NextCloudEngine.kt | 247 --------- .../retain/nextcloud/tasks/CreateDirTask.kt | 15 - .../nextcloud/tasks/DownloadFileTask.kt | 50 -- .../nextcloud/tasks/DownloadListJSONTask.kt | 58 --- .../tasks/DownloadMissingImagesTask.kt | 29 -- .../tasks/DownloadNoteCombosJSONTask.kt | 23 - .../nextcloud/tasks/DownloadNoteImagesTask.kt | 26 - .../nextcloud/tasks/ListFilesListTask.kt | 53 -- .../retain/nextcloud/tasks/ListFilesTask.kt | 42 -- .../huseli/retain/nextcloud/tasks/ListTask.kt | 61 --- .../retain/nextcloud/tasks/OperationTask.kt | 48 -- .../retain/nextcloud/tasks/RemoveFileTask.kt | 10 - .../retain/nextcloud/tasks/RemoveImageTask.kt | 9 - .../nextcloud/tasks/RemoveImagesTask.kt | 12 - .../nextcloud/tasks/RemoveOrphanImagesTask.kt | 29 -- .../us/huseli/retain/nextcloud/tasks/Task.kt | 85 ---- .../nextcloud/tasks/TestNextCloudTask.kt | 94 ---- .../retain/nextcloud/tasks/UploadFileTask.kt | 27 - .../retain/nextcloud/tasks/UploadImageTask.kt | 14 - .../nextcloud/tasks/UploadNoteCombosTask.kt | 37 -- .../us/huseli/retain/syncbackend/Engine.kt | 153 ++++++ .../retain/syncbackend/NextCloudEngine.kt | 244 +++++++++ .../huseli/retain/syncbackend/SFTPEngine.kt | 233 +++++++++ .../retain/syncbackend/tasks/CreateDirTask.kt | 8 + .../syncbackend/tasks/DownloadFileTask.kt | 24 + .../syncbackend/tasks/DownloadImagesTask.kt | 41 ++ .../syncbackend/tasks/DownloadListJSONTask.kt | 79 +++ .../tasks/DownloadNoteCombosJSONTask.kt | 23 + .../syncbackend/tasks/ListFilesListTask.kt | 48 ++ .../retain/syncbackend/tasks/ListFilesTask.kt | 16 + .../retain/syncbackend/tasks/ListTask.kt | 54 ++ .../retain/syncbackend/tasks/OperationTask.kt | 27 + .../syncbackend/tasks/RemoveFileTask.kt | 10 + .../syncbackend/tasks/RemoveImagesTask.kt | 22 + .../tasks/RemoveOrphanImagesTask.kt | 26 + .../tasks/SyncTask.kt | 43 +- .../huseli/retain/syncbackend/tasks/Task.kt | 72 +++ .../retain/syncbackend/tasks/TestTask.kt | 55 ++ .../syncbackend/tasks/UploadFileTask.kt | 21 + .../syncbackend/tasks/UploadImageTask.kt | 13 + .../tasks/UploadMissingImagesTask.kt | 37 +- .../syncbackend/tasks/UploadNoteCombosTask.kt | 32 ++ .../huseli/retain/viewmodels/NoteViewModel.kt | 15 +- .../retain/viewmodels/SettingsViewModel.kt | 468 +++++++++++------- app/src/main/res/values/strings.xml | 21 +- 58 files changed, 2082 insertions(+), 1439 deletions(-) create mode 100644 app/src/main/java/us/huseli/retain/compose/settings/SFTPSection.kt create mode 100644 app/src/main/java/us/huseli/retain/compose/settings/SyncBackendSection.kt delete mode 100644 app/src/main/java/us/huseli/retain/data/NextCloudRepository.kt create mode 100644 app/src/main/java/us/huseli/retain/data/SyncBackendRepository.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/NextCloudEngine.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/CreateDirTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadFileTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadListJSONTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadMissingImagesTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadNoteCombosJSONTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadNoteImagesTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/ListFilesListTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/ListFilesTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/ListTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/OperationTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveFileTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveImageTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveImagesTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveOrphanImagesTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/Task.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/TestNextCloudTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadFileTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadImageTask.kt delete mode 100644 app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadNoteCombosTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/Engine.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/NextCloudEngine.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/SFTPEngine.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/CreateDirTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadFileTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadImagesTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadListJSONTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadNoteCombosJSONTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/ListFilesListTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/ListFilesTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/ListTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/OperationTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveFileTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveImagesTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveOrphanImagesTask.kt rename app/src/main/java/us/huseli/retain/{nextcloud => syncbackend}/tasks/SyncTask.kt (77%) create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/Task.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/TestTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadFileTask.kt create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadImageTask.kt rename app/src/main/java/us/huseli/retain/{nextcloud => syncbackend}/tasks/UploadMissingImagesTask.kt (59%) create mode 100644 app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadNoteCombosTask.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cb54c7a..44d0f24 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -142,6 +142,9 @@ dependencies { // HTML parsing: implementation("org.jsoup:jsoup:1.16.1") + // SFTP: + implementation(group = "com.github.mwiede", name = "jsch", version = "0.2.9") + // testImplementation("junit:junit:4.13.2") // androidTestImplementation("androidx.test.ext:junit:1.1.5") // androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/app/src/main/java/us/huseli/retain/Constants.kt b/app/src/main/java/us/huseli/retain/Constants.kt index 4e439d8..06197b2 100644 --- a/app/src/main/java/us/huseli/retain/Constants.kt +++ b/app/src/main/java/us/huseli/retain/Constants.kt @@ -4,16 +4,22 @@ object Constants { const val DEFAULT_MAX_IMAGE_DIMEN = 2048 const val DEFAULT_MIN_COLUMN_WIDTH = 180 const val IMAGE_SUBDIR = "images" - const val NAV_ARG_NOTE_ID = "noteId" const val NAV_ARG_IMAGE_CAROUSEL_CURRENT_ID = "imageCarouselCurrentId" + const val NAV_ARG_NOTE_ID = "noteId" const val NEXTCLOUD_BASE_DIR = "/.retain" - const val NEXTCLOUD_IMAGE_SUBDIR = "images" - const val NEXTCLOUD_JSON_SUBDIR = "json" const val PREF_MIN_COLUMN_WIDTH = "minColumnWidth" - const val PREF_NEXTCLOUD_PASSWORD = "nextCloudPassword" const val PREF_NEXTCLOUD_BASE_DIR = "nextCloudBaseDir" + const val PREF_NEXTCLOUD_PASSWORD = "nextCloudPassword" const val PREF_NEXTCLOUD_URI = "nextCloudUri" const val PREF_NEXTCLOUD_USERNAME = "nextCloudUsername" - const val PREF_NEXTCLOUD_ENABLED = "nextCloudEnabled" + const val PREF_SFTP_BASE_DIR = "sftpBaseDir" + const val PREF_SFTP_HOSTNAME = "sftpHostname" + const val PREF_SFTP_PASSWORD = "sftpPassword" + const val PREF_SFTP_PORT = "sftpPort" + const val PREF_SFTP_USERNAME = "sftpUsername" + const val PREF_SYNC_BACKEND = "syncBackend" + const val SFTP_BASE_DIR = ".retain" + const val SYNCBACKEND_IMAGE_SUBDIR = "images" + const val SYNCBACKEND_JSON_SUBDIR = "json" const val ZIP_BUFFER_SIZE = 2048 } diff --git a/app/src/main/java/us/huseli/retain/Enums.kt b/app/src/main/java/us/huseli/retain/Enums.kt index 93cbb3a..15987cc 100644 --- a/app/src/main/java/us/huseli/retain/Enums.kt +++ b/app/src/main/java/us/huseli/retain/Enums.kt @@ -4,4 +4,9 @@ object Enums { enum class NoteType { TEXT, CHECKLIST } enum class Side { LEFT, RIGHT } enum class HomeScreenViewType { LIST, GRID } + enum class SyncBackend(val displayName: String) { + NONE("None"), + NEXTCLOUD("Nextcloud"), + SFTP("SFTP"), + } } diff --git a/app/src/main/java/us/huseli/retain/compose/HomeScreen.kt b/app/src/main/java/us/huseli/retain/compose/HomeScreen.kt index 50c9a8f..ff7fd8a 100644 --- a/app/src/main/java/us/huseli/retain/compose/HomeScreen.kt +++ b/app/src/main/java/us/huseli/retain/compose/HomeScreen.kt @@ -57,8 +57,9 @@ fun HomeScreen( onSettingsClick: () -> Unit, onDebugClick: () -> Unit, ) { - val isNextCloudRefreshing by viewModel.isNextCloudRefreshing.collectAsStateWithLifecycle(false) - val isNextCloudEnabled by settingsViewModel.isNextCloudEnabled.collectAsStateWithLifecycle() + val syncBackend by viewModel.syncBackend.collectAsStateWithLifecycle() + val isSyncBackendRefreshing by viewModel.isSyncBackendRefreshing.collectAsStateWithLifecycle(false) + val isSyncBackendEnabled by settingsViewModel.isSyncBackendEnabled.collectAsStateWithLifecycle(false) val notes by viewModel.notes.collectAsStateWithLifecycle(emptyList()) val images by viewModel.images.collectAsStateWithLifecycle(emptyList()) val checklistData by viewModel.checklistData.collectAsStateWithLifecycle(emptyList()) @@ -78,10 +79,10 @@ fun HomeScreen( } .fillMaxHeight() - if (isNextCloudEnabled) { + if (isSyncBackendEnabled) { val refreshState = rememberPullRefreshState( - refreshing = isNextCloudRefreshing, - onRefresh = { viewModel.syncNextCloud() }, + refreshing = isSyncBackendRefreshing, + onRefresh = { viewModel.syncBackend() }, ) lazyModifier = lazyModifier.pullRefresh(state = refreshState) } @@ -155,14 +156,17 @@ fun HomeScreen( } Column(modifier = lazyModifier.padding(innerPadding).fillMaxWidth()) { - if (isNextCloudRefreshing) { + if (isSyncBackendRefreshing) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.syncing_with_nextcloud), + text = stringResource( + R.string.syncing_with, + syncBackend?.displayName ?: stringResource(R.string.backend) + ), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(end = 4.dp), ) diff --git a/app/src/main/java/us/huseli/retain/compose/RetainScaffold.kt b/app/src/main/java/us/huseli/retain/compose/RetainScaffold.kt index fbd7faf..56684e2 100644 --- a/app/src/main/java/us/huseli/retain/compose/RetainScaffold.kt +++ b/app/src/main/java/us/huseli/retain/compose/RetainScaffold.kt @@ -36,7 +36,7 @@ fun RetainScaffold( content: @Composable (PaddingValues) -> Unit ) { val context = LocalContext.current - val nextCloudNeedsTesting by settingsViewModel.nextCloudNeedsTesting.collectAsStateWithLifecycle() + val syncBackendNeedsTesting by settingsViewModel.syncBackendNeedsTesting.collectAsStateWithLifecycle() val snackbarMessage by viewModel.logger.snackbarMessage.collectAsStateWithLifecycle(null) val scope = rememberCoroutineScope() val trashedNoteCount by viewModel.trashedNoteCount.collectAsStateWithLifecycle(0) @@ -79,11 +79,9 @@ fun RetainScaffold( } } - LaunchedEffect(nextCloudNeedsTesting) { - if (nextCloudNeedsTesting) settingsViewModel.testNextCloud { result -> - scope.launch { - if (!result.success) snackbarHostState.showSnackbar(result.getErrorMessage(context)) - } + LaunchedEffect(syncBackendNeedsTesting) { + if (syncBackendNeedsTesting) settingsViewModel.testSyncBackend { result -> + if (!result.success) scope.launch { snackbarHostState.showSnackbar(result.getErrorMessage(context)) } } } diff --git a/app/src/main/java/us/huseli/retain/compose/settings/NextCloudSection.kt b/app/src/main/java/us/huseli/retain/compose/settings/NextCloudSection.kt index 92f80d4..48f4dda 100644 --- a/app/src/main/java/us/huseli/retain/compose/settings/NextCloudSection.kt +++ b/app/src/main/java/us/huseli/retain/compose/settings/NextCloudSection.kt @@ -1,19 +1,16 @@ package us.huseli.retain.compose.settings import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.sharp.Check import androidx.compose.material.icons.sharp.Error import androidx.compose.material.icons.sharp.Visibility import androidx.compose.material.icons.sharp.VisibilityOff -import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedButton @@ -28,7 +25,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext @@ -37,17 +33,15 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import us.huseli.retain.Constants.PREF_NEXTCLOUD_BASE_DIR -import us.huseli.retain.Constants.PREF_NEXTCLOUD_ENABLED import us.huseli.retain.Constants.PREF_NEXTCLOUD_PASSWORD import us.huseli.retain.Constants.PREF_NEXTCLOUD_URI import us.huseli.retain.Constants.PREF_NEXTCLOUD_USERNAME import us.huseli.retain.R import us.huseli.retain.cleanUri import us.huseli.retain.compose.SweepLoadingOverlay -import us.huseli.retain.nextcloud.tasks.TestNextCloudTaskResult +import us.huseli.retain.syncbackend.tasks.TestTaskResult import us.huseli.retain.ui.theme.RetainColorDark import us.huseli.retain.ui.theme.RetainColorLight import us.huseli.retain.viewmodels.SettingsViewModel @@ -65,12 +59,11 @@ fun NextCloudSection( val baseDir by viewModel.nextCloudBaseDir.collectAsStateWithLifecycle("") val isTesting by viewModel.isNextCloudTesting.collectAsStateWithLifecycle() val isWorking by viewModel.isNextCloudWorking.collectAsStateWithLifecycle() - val isUrlFail by viewModel.isNextCloudUrlFail.collectAsStateWithLifecycle() - val isCredentialsFail by viewModel.isNextCloudCredentialsFail.collectAsStateWithLifecycle() - val isEnabled by viewModel.isNextCloudEnabled.collectAsStateWithLifecycle() + val isUrlError by viewModel.isNextCloudUrlError.collectAsStateWithLifecycle() + val isAuthError by viewModel.isNextCloudAuthError.collectAsStateWithLifecycle() val successMessage = stringResource(R.string.successfully_connected_to_nextcloud) - var testResult by remember { mutableStateOf(null) } + var testResult by remember { mutableStateOf(null) } var uriState by rememberSaveable(uri) { mutableStateOf(uri) } var isPasswordFieldFocused by rememberSaveable { mutableStateOf(false) } var isPasswordShown by rememberSaveable { mutableStateOf(false) } @@ -105,137 +98,102 @@ fun NextCloudSection( ) } - BaseSettingsSection( - modifier = modifier, - title = stringResource(R.string.nextcloud_sync), - key = "nextcloud", - viewModel = viewModel, - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = isEnabled, - onCheckedChange = { viewModel.updateField(PREF_NEXTCLOUD_ENABLED, it) } - ) - Text( - text = stringResource(R.string.enable_nextcloud_sync), - modifier = Modifier.padding(start = 8.dp) - ) - } - if (isEnabled) { - BoxWithConstraints { - Column { - Row(modifier = Modifier.fillMaxWidth()) { - OutlinedTextField( - modifier = Modifier - .onFocusChanged { - if (!it.isFocused) { - if (uriState.isNotEmpty()) { - uriState = cleanUri(uriState) - viewModel.updateField(PREF_NEXTCLOUD_URI, uriState) - } - } + BoxWithConstraints(modifier = modifier) { + Column { + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + modifier = Modifier + .onFocusChanged { + if (!it.isFocused) { + if (uriState.isNotEmpty()) { + uriState = cleanUri(uriState) + viewModel.updateField(PREF_NEXTCLOUD_URI, uriState) } - .fillMaxWidth(), - label = { Text(stringResource(R.string.nextcloud_uri)) }, - singleLine = true, - value = uriState, - onValueChange = { - uriState = it - viewModel.updateField(PREF_NEXTCLOUD_URI, it) - }, - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Next, - keyboardType = KeyboardType.Uri - ), - enabled = !isTesting, - trailingIcon = { - if (isWorking == true) workingIcon() - else if (isUrlFail) failIcon() } - ) - } - Row(modifier = Modifier.fillMaxWidth()) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - label = { Text(stringResource(R.string.nextcloud_username)) }, - singleLine = true, - value = username, - enabled = !isTesting, - onValueChange = { viewModel.updateField(PREF_NEXTCLOUD_USERNAME, it) }, - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), - trailingIcon = { - if (isWorking == true) workingIcon() - else if (isCredentialsFail) failIcon() - } - ) - } - Row(modifier = Modifier.fillMaxWidth()) { - OutlinedTextField( - modifier = Modifier - .fillMaxWidth() - .onFocusChanged { isPasswordFieldFocused = it.isFocused }, - label = { Text(stringResource(R.string.nextcloud_password)) }, - singleLine = true, - value = password, - onValueChange = { viewModel.updateField(PREF_NEXTCLOUD_PASSWORD, it) }, - visualTransformation = if (isPasswordShown) VisualTransformation.None else PasswordVisualTransformation(), - enabled = !isTesting, - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), - trailingIcon = { - if (isPasswordFieldFocused && isPasswordShown) - IconButton(onClick = { isPasswordShown = false }) { - Icon( - imageVector = Icons.Sharp.VisibilityOff, - contentDescription = stringResource(R.string.hide_password), - ) - } - else if (isPasswordFieldFocused) - IconButton(onClick = { isPasswordShown = true }) { - Icon( - imageVector = Icons.Sharp.Visibility, - contentDescription = stringResource(R.string.show_password), - ) - } - else if (isWorking == true) workingIcon() - else if (isCredentialsFail) failIcon() + } + .fillMaxWidth(), + label = { Text(stringResource(R.string.nextcloud_uri)) }, + singleLine = true, + value = uriState, + onValueChange = { + uriState = it + viewModel.updateField(PREF_NEXTCLOUD_URI, it) + }, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Uri + ), + enabled = !isTesting, + trailingIcon = { + if (isWorking == true) workingIcon() + else if (isUrlError) failIcon() + }, + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.nextcloud_username)) }, + singleLine = true, + value = username, + enabled = !isTesting, + onValueChange = { viewModel.updateField(PREF_NEXTCLOUD_USERNAME, it) }, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + trailingIcon = { + if (isWorking == true) workingIcon() + else if (isAuthError) failIcon() + }, + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { isPasswordFieldFocused = it.isFocused }, + label = { Text(stringResource(R.string.nextcloud_password)) }, + singleLine = true, + value = password, + onValueChange = { viewModel.updateField(PREF_NEXTCLOUD_PASSWORD, it) }, + visualTransformation = if (isPasswordShown) VisualTransformation.None else PasswordVisualTransformation(), + enabled = !isTesting, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + trailingIcon = { + if (isPasswordFieldFocused) + IconButton(onClick = { isPasswordShown = !isPasswordShown }) { + Icon( + imageVector = if (isPasswordShown) Icons.Sharp.VisibilityOff else Icons.Sharp.Visibility, + contentDescription = + if (isPasswordShown) stringResource(R.string.hide_password) + else stringResource(R.string.show_password), + ) } - ) - } - Row(modifier = Modifier.fillMaxWidth()) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = baseDir, - label = { Text(stringResource(R.string.nextcloud_base_path)) }, - singleLine = true, - onValueChange = { viewModel.updateField(PREF_NEXTCLOUD_BASE_DIR, it) }, - enabled = !isTesting, - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), - trailingIcon = { if (isWorking == true) workingIcon() }, - ) + else if (isWorking == true) workingIcon() + else if (isAuthError) failIcon() } - } - // if (isTesting) RadarLoadingOverlay(modifier = Modifier.matchParentSize()) - if (isTesting) SweepLoadingOverlay(modifier = Modifier.matchParentSize()) + ) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedButton( - onClick = { - viewModel.testNextCloud { result -> testResult = result } - }, - shape = ShapeDefaults.ExtraSmall, - enabled = !isTesting && uriState.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty(), - ) { - Text( - text = if (isTesting) stringResource(R.string.testing) else stringResource(R.string.test_connection), - ) - } + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = baseDir, + label = { Text(stringResource(R.string.nextcloud_base_path)) }, + singleLine = true, + onValueChange = { viewModel.updateField(PREF_NEXTCLOUD_BASE_DIR, it) }, + enabled = !isTesting, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + trailingIcon = { if (isWorking == true) workingIcon() }, + ) } } + if (isTesting) SweepLoadingOverlay(modifier = Modifier.matchParentSize()) + } + Row { + OutlinedButton( + onClick = { viewModel.testNextCloud { result -> testResult = result } }, + shape = ShapeDefaults.ExtraSmall, + enabled = !isTesting && uriState.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty(), + ) { + Text(text = if (isTesting) stringResource(R.string.testing) else stringResource(R.string.test_connection)) + } } } diff --git a/app/src/main/java/us/huseli/retain/compose/settings/SFTPSection.kt b/app/src/main/java/us/huseli/retain/compose/settings/SFTPSection.kt new file mode 100644 index 0000000..2ec1959 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/compose/settings/SFTPSection.kt @@ -0,0 +1,210 @@ +package us.huseli.retain.compose.settings + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.sharp.Check +import androidx.compose.material.icons.sharp.Visibility +import androidx.compose.material.icons.sharp.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import us.huseli.retain.Constants.PREF_SFTP_BASE_DIR +import us.huseli.retain.Constants.PREF_SFTP_HOSTNAME +import us.huseli.retain.Constants.PREF_SFTP_PASSWORD +import us.huseli.retain.Constants.PREF_SFTP_PORT +import us.huseli.retain.Constants.PREF_SFTP_USERNAME +import us.huseli.retain.R +import us.huseli.retain.compose.SweepLoadingOverlay +import us.huseli.retain.syncbackend.tasks.TestTaskResult +import us.huseli.retain.ui.theme.RetainColorDark +import us.huseli.retain.ui.theme.RetainColorLight +import us.huseli.retain.viewmodels.SettingsViewModel + +@Composable +fun PromptYesNoDialog( + modifier: Modifier = Modifier, + title: String, + message: String, + onYes: () -> Unit, + onNo: () -> Unit, +) { + AlertDialog( + modifier = modifier, + title = { Text(title) }, + text = { Text(message) }, + dismissButton = { + TextButton(onClick = onNo) { + Text(stringResource(R.string.no).uppercase()) + } + }, + confirmButton = { + TextButton(onClick = onYes) { + Text(stringResource(R.string.yes).uppercase()) + } + }, + onDismissRequest = {} + ) +} + +@Composable +fun SFTPSection(modifier: Modifier = Modifier, viewModel: SettingsViewModel) { + val baseDir by viewModel.sftpBaseDir.collectAsStateWithLifecycle() + val hostname by viewModel.sftpHostname.collectAsStateWithLifecycle() + val port by viewModel.sftpPort.collectAsStateWithLifecycle() + val username by viewModel.sftpUsername.collectAsStateWithLifecycle() + val password by viewModel.sftpPassword.collectAsStateWithLifecycle() + val promptYesNo by viewModel.sftpPromptYesNo.collectAsStateWithLifecycle() + val isTesting by viewModel.isSFTPTesting.collectAsStateWithLifecycle() + val isWorking by viewModel.isSFTPWorking.collectAsStateWithLifecycle() + var isPasswordShown by rememberSaveable { mutableStateOf(false) } + var testResult by remember { mutableStateOf(null) } + val colors = if (isSystemInDarkTheme()) RetainColorDark else RetainColorLight + var isPasswordFieldFocused by rememberSaveable { mutableStateOf(false) } + val workingIcon = @Composable { + Icon( + imageVector = Icons.Sharp.Check, + contentDescription = null, + tint = colors.Green, + ) + } + + promptYesNo?.let { + PromptYesNoDialog( + title = "SFTP", + message = it, + onYes = { viewModel.approveSFTPKey() }, + onNo = { viewModel.denySFTPKey() }, + ) + } + + BoxWithConstraints(modifier = modifier) { + Column { + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + modifier = Modifier.padding(end = 4.dp), + label = { Text(stringResource(R.string.sftp_hostname)) }, + singleLine = true, + value = hostname, + onValueChange = { viewModel.updateField(PREF_SFTP_HOSTNAME, it) }, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Uri, + ), + enabled = !isTesting, + trailingIcon = { + if (isWorking == true) workingIcon() + }, + ) + OutlinedTextField( + label = { Text(stringResource(R.string.sftp_port)) }, + singleLine = true, + value = port.toString(), + onValueChange = { viewModel.updateField(PREF_SFTP_PORT, it.toInt()) }, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next, + keyboardType = KeyboardType.Number, + ), + enabled = !isTesting, + trailingIcon = { + if (isWorking == true) workingIcon() + }, + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.sftp_username)) }, + singleLine = true, + value = username, + onValueChange = { viewModel.updateField(PREF_SFTP_USERNAME, it) }, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + enabled = !isTesting, + trailingIcon = { + if (isWorking == true) workingIcon() + }, + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { isPasswordFieldFocused = it.isFocused }, + label = { Text(stringResource(R.string.sftp_password)) }, + singleLine = true, + value = password, + onValueChange = { viewModel.updateField(PREF_SFTP_PASSWORD, it) }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ), + visualTransformation = + if (isPasswordShown) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + if (isPasswordFieldFocused) + IconButton(onClick = { isPasswordShown = !isPasswordShown }) { + Icon( + imageVector = if (isPasswordShown) Icons.Sharp.VisibilityOff else Icons.Sharp.Visibility, + contentDescription = + if (isPasswordShown) stringResource(R.string.hide_password) + else stringResource(R.string.show_password), + ) + } + else if (isWorking == true) workingIcon() + }, + enabled = !isTesting, + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.sftp_base_directory)) }, + singleLine = true, + value = baseDir, + onValueChange = { viewModel.updateField(PREF_SFTP_BASE_DIR, it) }, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), + supportingText = { Text(stringResource(R.string.relative_to_users_home_directory)) }, + enabled = !isTesting, + trailingIcon = { + if (isWorking == true) workingIcon() + }, + ) + } + } + if (isTesting) SweepLoadingOverlay(modifier = Modifier.matchParentSize()) + } + Row { + OutlinedButton( + onClick = { viewModel.testSFTP { result -> testResult = result } }, + shape = ShapeDefaults.ExtraSmall, + enabled = !isTesting && hostname.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty(), + ) { + Text(text = if (isTesting) stringResource(R.string.testing) else stringResource(R.string.test_connection)) + } + } +} diff --git a/app/src/main/java/us/huseli/retain/compose/settings/SettingsScreen.kt b/app/src/main/java/us/huseli/retain/compose/settings/SettingsScreen.kt index 0eb192d..0eaaf8b 100644 --- a/app/src/main/java/us/huseli/retain/compose/settings/SettingsScreen.kt +++ b/app/src/main/java/us/huseli/retain/compose/settings/SettingsScreen.kt @@ -66,7 +66,7 @@ fun SettingsScreen( GeneralSection(modifier = Modifier.fillMaxWidth(), viewModel = viewModel) } item { - NextCloudSection( + SyncBackendSection( modifier = Modifier.fillMaxWidth(), viewModel = viewModel, snackbarHostState = snackbarHostState, diff --git a/app/src/main/java/us/huseli/retain/compose/settings/SyncBackendSection.kt b/app/src/main/java/us/huseli/retain/compose/settings/SyncBackendSection.kt new file mode 100644 index 0000000..97ddb30 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/compose/settings/SyncBackendSection.kt @@ -0,0 +1,80 @@ +package us.huseli.retain.compose.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import us.huseli.retain.Constants.PREF_SYNC_BACKEND +import us.huseli.retain.Enums.SyncBackend +import us.huseli.retain.R +import us.huseli.retain.viewmodels.SettingsViewModel + +@Composable +fun SyncBackendRadioButton( + modifier: Modifier = Modifier, + text: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = selected, + onClick = onClick + ) + Text( + text = text, + modifier = Modifier + .padding(start = 8.dp) + .clickable { onClick() } + ) + } +} + +@Composable +fun SyncBackendSection( + modifier: Modifier = Modifier, + viewModel: SettingsViewModel, + snackbarHostState: SnackbarHostState, +) { + val syncBackend by viewModel.syncBackend.collectAsStateWithLifecycle() + + BaseSettingsSection( + modifier = modifier, + title = stringResource(R.string.backend_sync), + key = "syncBackend", + viewModel = viewModel, + ) { + SyncBackendRadioButton( + text = stringResource(R.string.do_not_sync), + selected = syncBackend == SyncBackend.NONE, + onClick = { viewModel.updateField(PREF_SYNC_BACKEND, SyncBackend.NONE) }, + ) + SyncBackendRadioButton( + text = stringResource(R.string.sync_with_nextcloud), + selected = syncBackend == SyncBackend.NEXTCLOUD, + onClick = { viewModel.updateField(PREF_SYNC_BACKEND, SyncBackend.NEXTCLOUD) }, + ) + SyncBackendRadioButton( + text = stringResource(R.string.sync_with_sftp), + selected = syncBackend == SyncBackend.SFTP, + onClick = { viewModel.updateField(PREF_SYNC_BACKEND, SyncBackend.SFTP) }, + ) + + if (syncBackend == SyncBackend.NEXTCLOUD) { + NextCloudSection(viewModel = viewModel, snackbarHostState = snackbarHostState) + } + if (syncBackend == SyncBackend.SFTP) { + SFTPSection(viewModel = viewModel) + } + } +} diff --git a/app/src/main/java/us/huseli/retain/data/NextCloudRepository.kt b/app/src/main/java/us/huseli/retain/data/NextCloudRepository.kt deleted file mode 100644 index 0eae49b..0000000 --- a/app/src/main/java/us/huseli/retain/data/NextCloudRepository.kt +++ /dev/null @@ -1,87 +0,0 @@ -package us.huseli.retain.data - -import android.content.Context -import android.content.SharedPreferences -import android.net.Uri -import androidx.preference.PreferenceManager -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import us.huseli.retain.Constants -import us.huseli.retain.LogInterface -import us.huseli.retain.Logger -import us.huseli.retain.data.entities.Image -import us.huseli.retain.nextcloud.NextCloudEngine -import us.huseli.retain.nextcloud.tasks.RemoveImagesTask -import us.huseli.retain.nextcloud.tasks.SyncTask -import us.huseli.retain.nextcloud.tasks.TestNextCloudTaskResult -import us.huseli.retain.nextcloud.tasks.UploadNoteCombosTask -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class NextCloudRepository @Inject constructor( - @ApplicationContext private val context: Context, - private val imageDao: ImageDao, - private val nextCloudEngine: NextCloudEngine, - private val noteDao: NoteDao, - private val checklistItemDao: ChecklistItemDao, - private val ioScope: CoroutineScope, - private val database: Database, - override val logger: Logger, -) : SharedPreferences.OnSharedPreferenceChangeListener, LogInterface { - private val imageDir = File(context.filesDir, Constants.IMAGE_SUBDIR).apply { mkdirs() } - private val preferences = PreferenceManager.getDefaultSharedPreferences(context) - val hasActiveTasks = nextCloudEngine.hasActiveTasks - - init { - sync() - preferences.registerOnSharedPreferenceChangeListener(this) - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - if (key == Constants.PREF_NEXTCLOUD_BASE_DIR) sync() - } - - fun sync() { - ioScope.launch { - @Suppress("Destructure") - SyncTask( - engine = nextCloudEngine, - localCombos = noteDao.listAllCombos().map { - it.copy(databaseVersion = database.openHelper.readableDatabase.version) - }, - onRemoteComboUpdated = { combo -> - ioScope.launch { - noteDao.upsert(combo.note) - checklistItemDao.replace(combo.note.id, combo.checklistItems) - imageDao.replace(combo.note.id, combo.images) - } - }, - localImageDir = imageDir, - deletedNoteIds = noteDao.listDeletedIds(), - ).run() - } - } - - fun removeImages(images: Collection) = RemoveImagesTask(nextCloudEngine, images).run() - - fun test( - uri: Uri, - username: String, - password: String, - baseDir: String, - onResult: (TestNextCloudTaskResult) -> Unit - ) = nextCloudEngine.testClient(uri, username, password, baseDir) { result -> onResult(result) } - - suspend fun uploadNotes() { - val combos = noteDao.listAllCombos() - UploadNoteCombosTask( - engine = nextCloudEngine, - combos = combos.map { it.copy(databaseVersion = database.openHelper.readableDatabase.version) }, - ).run { result -> - if (!result.success) logError("Failed to upload note(s) to Nextcloud", result.error) - } - } -} diff --git a/app/src/main/java/us/huseli/retain/data/NoteRepository.kt b/app/src/main/java/us/huseli/retain/data/NoteRepository.kt index 4664067..8d70ee6 100644 --- a/app/src/main/java/us/huseli/retain/data/NoteRepository.kt +++ b/app/src/main/java/us/huseli/retain/data/NoteRepository.kt @@ -53,7 +53,6 @@ class NoteRepository @Inject constructor( } } - val nextCloudNeedsTesting = MutableStateFlow(true) val checklistItems: Flow> = checklistItemDao.flowList() val images: Flow> = imageDao.flowList().map { images -> images.map { it.copy(imageBitmap = getImageBitmap(it.filename)) } diff --git a/app/src/main/java/us/huseli/retain/data/SyncBackendRepository.kt b/app/src/main/java/us/huseli/retain/data/SyncBackendRepository.kt new file mode 100644 index 0000000..f86a841 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/data/SyncBackendRepository.kt @@ -0,0 +1,103 @@ +package us.huseli.retain.data + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import us.huseli.retain.Constants +import us.huseli.retain.Constants.PREF_SYNC_BACKEND +import us.huseli.retain.Enums.SyncBackend +import us.huseli.retain.LogInterface +import us.huseli.retain.Logger +import us.huseli.retain.data.entities.Image +import us.huseli.retain.syncbackend.Engine +import us.huseli.retain.syncbackend.NextCloudEngine +import us.huseli.retain.syncbackend.SFTPEngine +import us.huseli.retain.syncbackend.tasks.RemoveImagesTask +import us.huseli.retain.syncbackend.tasks.SyncTask +import us.huseli.retain.syncbackend.tasks.UploadNoteCombosTask +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SyncBackendRepository @Inject constructor( + @ApplicationContext context: Context, + private val nextCloudEngine: NextCloudEngine, + private val sftpEngine: SFTPEngine, + override val logger: Logger, + private val noteDao: NoteDao, + private val database: Database, + private val ioScope: CoroutineScope, + private val checklistItemDao: ChecklistItemDao, + private val imageDao: ImageDao, +) : SharedPreferences.OnSharedPreferenceChangeListener, LogInterface { + private val imageDir = File(context.filesDir, Constants.IMAGE_SUBDIR).apply { mkdirs() } + private val preferences = PreferenceManager.getDefaultSharedPreferences(context) + private val _syncBackend: MutableStateFlow = MutableStateFlow( + preferences.getString(PREF_SYNC_BACKEND, null)?.let { SyncBackend.valueOf(it) } + ) + private val engine: Engine? + get() = when (_syncBackend.value) { + SyncBackend.NEXTCLOUD -> nextCloudEngine + SyncBackend.SFTP -> sftpEngine + else -> null + } + + val hasActiveTasks: Flow = engine?.hasActiveTasks ?: flowOf(false) + val needsTesting = MutableStateFlow(true) + val syncBackend = _syncBackend.asStateFlow() + + init { + preferences.registerOnSharedPreferenceChangeListener(this) + ioScope.launch { sync() } + } + + fun removeImages(images: Collection) = engine?.let { RemoveImagesTask(it, images).run() } + + suspend fun sync() { + @Suppress("Destructure") + engine?.let { + SyncTask( + engine = it, + localCombos = noteDao.listAllCombos().map { combo -> + combo.copy(databaseVersion = database.openHelper.readableDatabase.version) + }, + onRemoteComboUpdated = { combo -> + ioScope.launch { + noteDao.upsert(combo.note) + checklistItemDao.replace(combo.note.id, combo.checklistItems) + imageDao.replace(combo.note.id, combo.images) + } + }, + localImageDir = imageDir, + deletedNoteIds = noteDao.listDeletedIds(), + ).run() + } + } + + suspend fun uploadNotes() { + engine?.let { + val combos = noteDao.listAllCombos() + UploadNoteCombosTask( + engine = it, + combos = combos.map { combo -> + combo.copy(databaseVersion = database.openHelper.readableDatabase.version) + }, + ).run { result -> + if (!result.success) logError("Failed to upload note(s) to Nextcloud: ${result.message}") + } + } + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + if (key == PREF_SYNC_BACKEND) + _syncBackend.value = preferences.getString(key, null)?.let { SyncBackend.valueOf(it) } + } +} diff --git a/app/src/main/java/us/huseli/retain/di/IoScopeModule.kt b/app/src/main/java/us/huseli/retain/di/IoScopeModule.kt index 37b07cf..8a0ff19 100644 --- a/app/src/main/java/us/huseli/retain/di/IoScopeModule.kt +++ b/app/src/main/java/us/huseli/retain/di/IoScopeModule.kt @@ -15,4 +15,4 @@ object IoScopeModule { @Provides @Singleton fun provideIoScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) -} \ No newline at end of file +} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/NextCloudEngine.kt b/app/src/main/java/us/huseli/retain/nextcloud/NextCloudEngine.kt deleted file mode 100644 index a5dd374..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/NextCloudEngine.kt +++ /dev/null @@ -1,247 +0,0 @@ -package us.huseli.retain.nextcloud - -import android.content.Context -import android.content.SharedPreferences -import android.net.Uri -import android.os.Handler -import android.os.Looper -import android.util.Log -import androidx.preference.PreferenceManager -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import com.owncloud.android.lib.common.OwnCloudClient -import com.owncloud.android.lib.common.OwnCloudClientFactory -import com.owncloud.android.lib.common.OwnCloudCredentialsFactory -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapMerge -import kotlinx.coroutines.launch -import us.huseli.retain.Constants.NEXTCLOUD_BASE_DIR -import us.huseli.retain.Constants.PREF_NEXTCLOUD_BASE_DIR -import us.huseli.retain.Constants.PREF_NEXTCLOUD_ENABLED -import us.huseli.retain.Constants.PREF_NEXTCLOUD_PASSWORD -import us.huseli.retain.Constants.PREF_NEXTCLOUD_URI -import us.huseli.retain.Constants.PREF_NEXTCLOUD_USERNAME -import us.huseli.retain.InstantAdapter -import us.huseli.retain.LogInterface -import us.huseli.retain.Logger -import us.huseli.retain.nextcloud.tasks.BaseTask -import us.huseli.retain.nextcloud.tasks.TestNextCloudTask -import us.huseli.retain.nextcloud.tasks.TestNextCloudTaskResult -import java.io.File -import java.time.Instant -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class NextCloudEngine @Inject constructor( - @ApplicationContext internal val context: Context, - internal val ioScope: CoroutineScope, - override val logger: Logger, -) : SharedPreferences.OnSharedPreferenceChangeListener, LogInterface { - internal val gson: Gson = GsonBuilder() - .registerTypeAdapter(Instant::class.java, InstantAdapter()) - .create() - internal val tempDirUp = File(context.cacheDir, "up").apply { mkdirs() } - internal val tempDirDown = File(context.cacheDir, "down").apply { mkdirs() } - internal val listenerHandler = Handler(Looper.getMainLooper()) - - private val preferences = PreferenceManager.getDefaultSharedPreferences(context) - // private var _tasks: List> = listOf() - private val tasks = MutableStateFlow>>(emptyList()) - private var isTestScheduled = false - private var status = STATUS_DISABLED - - val hasActiveTasks = tasks.flatMapMerge { tasks -> - combine(*tasks.map { it.status }.toTypedArray()) { statuses -> - statuses.any { it != BaseTask.STATUS_FINISHED } - } - } - - private var isEnabled: Boolean = false - set(value) { - if (field != value) { - field = value - updateClient(isEnabled = value) - } - } - - private var uri: Uri = Uri.EMPTY - set(value) { - if (field != value) { - field = value - updateClient(uri = value) - } - } - - private var username = "" - set(value) { - if (field != value) { - field = value - updateClient(username = value) - } - } - - private var password = "" - set(value) { - if (field != value) { - field = value - updateClient(password = value) - } - } - - private var baseDir = NEXTCLOUD_BASE_DIR - set(value) { - if (field != value.trimEnd('/')) field = value.trimEnd('/') - } - - internal val client: OwnCloudClient = - OwnCloudClientFactory.createOwnCloudClient(uri, context, true).apply { - setDefaultTimeouts(120_000, 120_000) - } - - private val runningTasks: List> - get() = tasks.value.filter { it.status.value == BaseTask.STATUS_RUNNING } - - private val runningNonMetaTasks: List> - get() = runningTasks.filter { !it.isMetaTask } - - private val waitingTasks: List> - get() = tasks.value.filter { it.status.value == BaseTask.STATUS_WAITING } - - init { - // These must be set here and not inline, because otherwise the set() - // methods are not run. - uri = Uri.parse(preferences.getString(PREF_NEXTCLOUD_URI, "") ?: "") - username = preferences.getString(PREF_NEXTCLOUD_USERNAME, "") ?: "" - password = preferences.getString(PREF_NEXTCLOUD_PASSWORD, "") ?: "" - baseDir = preferences.getString(PREF_NEXTCLOUD_BASE_DIR, NEXTCLOUD_BASE_DIR) ?: NEXTCLOUD_BASE_DIR - isEnabled = preferences.getBoolean(PREF_NEXTCLOUD_ENABLED, false) - preferences.registerOnSharedPreferenceChangeListener(this) - } - - fun getAbsolutePath(vararg segments: String) = - listOf( - baseDir.trimEnd('/'), - *segments.map { it.trim('/') }.toTypedArray() - ).joinToString("/") - - private fun logTasks() { - log("Running tasks: ${runningTasks.map { it.javaClass.simpleName }}") - log("Waiting tasks: ${waitingTasks.map { it.javaClass.simpleName }}") - } - - fun registerTask(task: BaseTask<*>, triggerStatus: Int, callback: () -> Unit) { - log( - "registerTask: task=${task.javaClass.simpleName}, triggerStatus=$triggerStatus, status=$status", - level = Log.DEBUG - ) - tasks.value = tasks.value.toMutableList().apply { add(task) } - task.addOnFinishedListener { logTasks() } - if (status >= triggerStatus && runningNonMetaTasks.size < 3) callback() - else { - ioScope.launch { - while (status < triggerStatus || runningNonMetaTasks.size >= 3) delay(1_000) - callback() - } - } - logTasks() - } - - fun testClient( - uri: Uri, - username: String, - password: String, - baseDir: String, - callback: ((TestNextCloudTaskResult) -> Unit)? = null - ) { - this.uri = uri - this.username = username - this.password = password - this.baseDir = baseDir - if (this.uri.host != null) testClient(callback) - } - - private fun testClient(callback: ((TestNextCloudTaskResult) -> Unit)? = null) { - if (status == STATUS_TESTING) { - ioScope.launch { - while (status == STATUS_TESTING) delay(100) - testClient(callback) - } - } else if (status == STATUS_DISABLED) { - ioScope.launch { - while (status == STATUS_DISABLED) delay(10_000) - testClient(callback) - } - } else if (status < STATUS_AUTH_ERROR) { - // On auth error, don't even try anything until URL/username/PW has changed. - status = STATUS_TESTING - TestNextCloudTask(this).run(STATUS_TESTING) { result -> - status = result.status - callback?.invoke(result) - - // Schedule low-frequency retries for as long as needed: - if (status < STATUS_AUTH_ERROR && !isTestScheduled) { - ioScope.launch { - isTestScheduled = true - while (status < STATUS_AUTH_ERROR) { - delay(30_000) - testClient() - } - isTestScheduled = false - } - } - } - } else callback?.invoke( - TestNextCloudTaskResult( - success = status == STATUS_OK, - error = null, - status = status, - ) - ) - } - - private fun updateClient( - uri: Uri? = null, - username: String? = null, - password: String? = null, - isEnabled: Boolean? = null - ) { - log( - "updateClient: uri=$uri, username=$username, password=$password, isEnabled=$isEnabled", - level = Log.DEBUG - ) - if (uri != null) client.baseUri = uri - if (username != null || password != null) { - client.credentials = OwnCloudCredentialsFactory.newBasicCredentials( - username ?: this.username, - password ?: this.password - ) - if (username != null) client.userId = username - } - if (isEnabled == false) status = STATUS_DISABLED - else if (isEnabled == true || status != STATUS_DISABLED) status = STATUS_READY - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - when (key) { - PREF_NEXTCLOUD_URI -> uri = Uri.parse(preferences.getString(key, "") ?: "") - PREF_NEXTCLOUD_USERNAME -> username = preferences.getString(key, "") ?: "" - PREF_NEXTCLOUD_PASSWORD -> password = preferences.getString(key, "") ?: "" - PREF_NEXTCLOUD_BASE_DIR -> baseDir = preferences.getString(key, NEXTCLOUD_BASE_DIR) ?: NEXTCLOUD_BASE_DIR - PREF_NEXTCLOUD_ENABLED -> isEnabled = preferences.getBoolean(key, false) - } - } - - companion object { - const val STATUS_DISABLED = 0 - const val STATUS_TESTING = 1 - const val STATUS_READY = 2 - const val STATUS_ERROR = 3 - const val STATUS_AUTH_ERROR = 4 - const val STATUS_OK = 5 - } -} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/CreateDirTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/CreateDirTask.kt deleted file mode 100644 index e762b2e..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/CreateDirTask.kt +++ /dev/null @@ -1,15 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import com.owncloud.android.lib.common.operations.RemoteOperationResult -import com.owncloud.android.lib.resources.files.CreateFolderRemoteOperation -import us.huseli.retain.nextcloud.NextCloudEngine - -/** Create: 1 arbitrary directory */ -class CreateDirTask(engine: NextCloudEngine, remoteDir: String) : OperationTask(engine) { - override val remoteOperation = CreateFolderRemoteOperation(remoteDir, true) - - override fun isRemoteOperationSuccessful(remoteOperationResult: RemoteOperationResult<*>): Boolean { - /** A little more lax than parent implementation. */ - return remoteOperationResult.isSuccess || remoteOperationResult.code == RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS - } -} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadFileTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadFileTask.kt deleted file mode 100644 index 5e14fb0..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadFileTask.kt +++ /dev/null @@ -1,50 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import com.owncloud.android.lib.common.operations.RemoteOperationResult -import com.owncloud.android.lib.resources.files.DownloadFileRemoteOperation -import us.huseli.retain.nextcloud.NextCloudEngine -import java.io.File - -/** Down: 1 arbitrary file */ -abstract class BaseDownloadFileTask( - engine: NextCloudEngine, - val remotePath: String, - private val tempDir: File, -) : BaseOperationTask(engine) { - override val remoteOperation = DownloadFileRemoteOperation(remotePath, tempDir.absolutePath) - - override fun onSuccessfulRemoteOperation(remoteOperationResult: RemoteOperationResult<*>) { - val localTempFile = File(tempDir, remotePath) - if (!localTempFile.isFile) - failWithMessage("$remotePath: $localTempFile is not a file") - else - handleDownloadedFile(localTempFile) - } - - open fun handleDownloadedFile(file: File) = notifyIfFinished() -} - -open class DownloadFileTask( - engine: NextCloudEngine, - remotePath: String, - tempDir: File, - private val localFile: File, -) : BaseDownloadFileTask(engine, remotePath, tempDir) { - override val successMessageString = "Successfully downloaded $remotePath to $localFile" - - override fun getResult() = OperationTaskResult( - success = success, - error = error, - remoteOperationResult = remoteOperationResult, - ) - - override fun handleDownloadedFile(file: File) { - if (!file.renameTo(localFile)) - failWithMessage("$remotePath: Could not move $file to $localFile") - else - notifyIfFinished() - } -} - - - diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadListJSONTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadListJSONTask.kt deleted file mode 100644 index 74ffab4..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadListJSONTask.kt +++ /dev/null @@ -1,58 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import com.owncloud.android.lib.common.operations.RemoteOperationResult -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import us.huseli.retain.LogMessage -import us.huseli.retain.nextcloud.NextCloudEngine -import java.io.File -import java.io.FileReader - -class DownloadListJSONTaskResult( - success: Boolean, - error: LogMessage?, - remoteOperationResult: RemoteOperationResult<*>?, - val objects: Collection? -) : OperationTaskResult(success, error, remoteOperationResult) - -abstract class DownloadListJSONTask( - engine: NextCloudEngine, - remotePath: String -) : BaseDownloadFileTask>( - engine = engine, - remotePath = remotePath, - tempDir = File(engine.context.cacheDir, "down").apply { mkdirs() }, -) { - private var _finished = false - private var objects: Collection? = null - - override fun isFinished() = _finished || !success - override fun getResult() = DownloadListJSONTaskResult( - success = success, - error = error, - remoteOperationResult = remoteOperationResult, - objects = objects, - ) - - abstract fun deserialize(json: String): Collection? - - override fun handleDownloadedFile(file: File) { - engine.ioScope.launch { - withContext(Dispatchers.IO) { - try { - val json = FileReader(file).use { it.readText() } - objects = deserialize(json) - _finished = true - if (objects != null) - notifyIfFinished("Successfully parsed $remotePath; result=$objects") - else failWithMessage("$remotePath: result is null") - } catch (e: Exception) { - failWithMessage("$remotePath: $e") - } finally { - file.delete() - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadMissingImagesTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadMissingImagesTask.kt deleted file mode 100644 index 1b3c9d8..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadMissingImagesTask.kt +++ /dev/null @@ -1,29 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import us.huseli.retain.Constants -import us.huseli.retain.data.entities.Image -import us.huseli.retain.nextcloud.NextCloudEngine -import java.io.File - -/** - * Down: 0..n images - * - * We're more lax with the success status here, because the whole operation shouldn't be considered a failure if - * one of 10 locally missing images was also missing on remote. - */ -class DownloadMissingImagesTask( - engine: NextCloudEngine, - missingImages: Collection -) : ListTask( - engine = engine, - objects = missingImages -) { - override val failOnUnsuccessfulChildTask = false - - override fun getChildTask(obj: Image) = DownloadFileTask( - engine = engine, - remotePath = engine.getAbsolutePath(Constants.NEXTCLOUD_IMAGE_SUBDIR, obj.filename), - tempDir = engine.tempDirDown, - localFile = File(File(engine.context.filesDir, "images"), obj.filename), - ) -} \ No newline at end of file diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadNoteCombosJSONTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadNoteCombosJSONTask.kt deleted file mode 100644 index baa0a59..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadNoteCombosJSONTask.kt +++ /dev/null @@ -1,23 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import com.google.gson.reflect.TypeToken -import us.huseli.retain.Constants -import us.huseli.retain.data.entities.NoteCombo -import us.huseli.retain.nextcloud.NextCloudEngine -import java.util.UUID - -class DownloadNoteCombosJSONTask( - engine: NextCloudEngine, - private val deletedNoteIds: Collection -) : DownloadListJSONTask( - engine = engine, - remotePath = engine.getAbsolutePath(Constants.NEXTCLOUD_JSON_SUBDIR, "noteCombos.json") -) { - override fun deserialize(json: String): Collection? { - val listType = object : TypeToken>() {} - @Suppress("RemoveExplicitTypeArguments") - return engine.gson.fromJson>(json, listType)?.filter { - !deletedNoteIds.contains(it.note.id) - } - } -} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadNoteImagesTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadNoteImagesTask.kt deleted file mode 100644 index f6f892b..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/DownloadNoteImagesTask.kt +++ /dev/null @@ -1,26 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import us.huseli.retain.Constants -import us.huseli.retain.data.entities.Image -import us.huseli.retain.data.entities.NoteCombo -import us.huseli.retain.nextcloud.NextCloudEngine -import java.io.File - -/** Down: 0..n image files */ -class DownloadNoteImagesTask( - engine: NextCloudEngine, - noteCombo: NoteCombo -) : ListTask( - engine = engine, - objects = noteCombo.images -) { - override val startMessageString = "Starting download of ${noteCombo.images}" - override val failOnUnsuccessfulChildTask = false - - override fun getChildTask(obj: Image) = DownloadFileTask( - engine = engine, - remotePath = engine.getAbsolutePath(Constants.NEXTCLOUD_IMAGE_SUBDIR, obj.filename), - tempDir = engine.tempDirDown, - localFile = File(File(engine.context.filesDir, "images"), obj.filename), - ) -} \ No newline at end of file diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/ListFilesListTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/ListFilesListTask.kt deleted file mode 100644 index 049be4d..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/ListFilesListTask.kt +++ /dev/null @@ -1,53 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import com.owncloud.android.lib.common.operations.RemoteOperationResult -import com.owncloud.android.lib.resources.files.model.RemoteFile -import us.huseli.retain.nextcloud.NextCloudEngine - -abstract class ListFilesListTask>( - engine: NextCloudEngine, - remoteDir: String, - filter: (RemoteFile) -> Boolean -) : BaseListFilesTask(engine, remoteDir, filter) { - private var onEachCallback: ((RemoteFile, CRT) -> Unit)? = null - private val successfulRemoteFiles = mutableListOf() - private val unsuccessfulRemoteFiles = mutableListOf() - protected open val failOnUnsuccessfulChildTask = true - override val isMetaTask = true - - abstract fun getChildTask(remoteFile: RemoteFile): CT? - - override fun isFinished() = - super.isFinished() && - ( - (successfulRemoteFiles.size + unsuccessfulRemoteFiles.size == remoteFiles.size) || - (failOnUnsuccessfulChildTask && !success) - ) - - fun run( - triggerStatus: Int = NextCloudEngine.STATUS_OK, - onEachCallback: ((RemoteFile, CRT) -> Unit)?, - onReadyCallback: ((RT) -> Unit)? - ) { - this.onEachCallback = onEachCallback - super.run(triggerStatus, onReadyCallback) - } - - override fun onSuccessfulRemoteOperation(remoteOperationResult: RemoteOperationResult<*>) { - @Suppress("DEPRECATION") - remoteFiles.addAll(remoteOperationResult.data.filterIsInstance().filter(filter)) - remoteFiles.forEach { remoteFile -> - getChildTask(remoteFile)?.run(triggerStatus) { result -> - if (result.success) { - successfulRemoteFiles.add(remoteFile) - } else { - unsuccessfulRemoteFiles.add(remoteFile) - if (failOnUnsuccessfulChildTask) failWithMessage(result.error) - } - onEachCallback?.invoke(remoteFile, result) - notifyIfFinished() - } - } - notifyIfFinished() - } -} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/ListFilesTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/ListFilesTask.kt deleted file mode 100644 index d9660d5..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/ListFilesTask.kt +++ /dev/null @@ -1,42 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import com.owncloud.android.lib.common.operations.RemoteOperationResult -import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation -import com.owncloud.android.lib.resources.files.model.RemoteFile -import us.huseli.retain.LogMessage -import us.huseli.retain.nextcloud.NextCloudEngine - -open class ListFilesTaskResult( - success: Boolean, - error: LogMessage?, - remoteOperationResult: RemoteOperationResult<*>?, - val remoteFiles: List, -) : OperationTaskResult(success, error, remoteOperationResult) - -/** List: arbitrary files */ -abstract class BaseListFilesTask( - engine: NextCloudEngine, - remoteDir: String, - protected val filter: (RemoteFile) -> Boolean, -) : BaseOperationTask(engine) { - override val remoteOperation = ReadFolderRemoteOperation(remoteDir) - protected val remoteFiles = mutableListOf() - - override fun onSuccessfulRemoteOperation(remoteOperationResult: RemoteOperationResult<*>) { - @Suppress("DEPRECATION") - remoteFiles.addAll(remoteOperationResult.data.filterIsInstance().filter(filter)) - super.onSuccessfulRemoteOperation(remoteOperationResult) - } -} - -open class ListFilesTask(engine: NextCloudEngine, remoteDir: String, filter: (RemoteFile) -> Boolean) : - BaseListFilesTask(engine, remoteDir, filter) { - override fun getResult() = ListFilesTaskResult( - success = success, - error = error, - remoteOperationResult = remoteOperationResult, - remoteFiles = remoteFiles, - ) -} - - diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/ListTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/ListTask.kt deleted file mode 100644 index 897ebb9..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/ListTask.kt +++ /dev/null @@ -1,61 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import us.huseli.retain.nextcloud.NextCloudEngine - -/** - * Default behaviour: Fail immediately when any child task fails. If this is - * not desired, override isReady() and/or onUnsuccessfulChildTask(). - * - * By default, this.error will be the same as the error from the latest failed - * child task, which is not super optimal, but I guess we can live with it. - */ -abstract class BaseListTask, LT>( - engine: NextCloudEngine, - private val objects: Collection, -) : BaseTask(engine) { - private var onEachCallback: ((LT, CRT) -> Unit)? = null - private val successfulObjects = mutableListOf() - private val unsuccessfulObjects = mutableListOf() - protected open val failOnUnsuccessfulChildTask = true - override val isMetaTask = true - - abstract fun getChildTask(obj: LT): CT? - - open fun processChildTaskResult(obj: LT, result: CRT) {} - - override fun isFinished() = - (successfulObjects.size + unsuccessfulObjects.size == objects.size) || - (failOnUnsuccessfulChildTask && !success) - - fun run( - triggerStatus: Int = NextCloudEngine.STATUS_OK, - onEachCallback: ((LT, CRT) -> Unit)?, - onReadyCallback: ((RT) -> Unit)? - ) { - this.onEachCallback = onEachCallback - super.run(triggerStatus, onReadyCallback) - } - - override fun start() { - objects.forEach { obj -> - getChildTask(obj)?.run(triggerStatus) { result -> - processChildTaskResult(obj, result) - if (result.success) { - successfulObjects.add(obj) - } else { - unsuccessfulObjects.add(obj) - if (failOnUnsuccessfulChildTask) failWithMessage(result.error) - } - onEachCallback?.invoke(obj, result) - notifyIfFinished() - } - } - notifyIfFinished() - } -} - - -abstract class ListTask, LT>(engine: NextCloudEngine, objects: Collection) : - BaseListTask(engine, objects) { - override fun getResult() = TaskResult(success, error) -} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/OperationTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/OperationTask.kt deleted file mode 100644 index f482814..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/OperationTask.kt +++ /dev/null @@ -1,48 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import com.owncloud.android.lib.common.operations.RemoteOperation -import com.owncloud.android.lib.common.operations.RemoteOperationResult -import us.huseli.retain.LogMessage -import us.huseli.retain.nextcloud.NextCloudEngine - -open class OperationTaskResult( - success: Boolean, - error: LogMessage? = null, - val remoteOperationResult: RemoteOperationResult<*>? = null -) : TaskResult(success, error) - -abstract class BaseOperationTask(engine: NextCloudEngine) : BaseTask(engine) { - abstract val remoteOperation: RemoteOperation<*> - var remoteOperationResult: RemoteOperationResult<*>? = null - - open fun onSuccessfulRemoteOperation(remoteOperationResult: RemoteOperationResult<*>) { - notifyIfFinished() - } - - open fun onUnsuccessfulRemoteOperation(remoteOperationResult: RemoteOperationResult<*>) { - failWithMessage(remoteOperationResult.logMessage ?: remoteOperationResult.message) - } - - override fun isFinished() = remoteOperationResult != null - - open fun isRemoteOperationSuccessful(remoteOperationResult: RemoteOperationResult<*>) = - remoteOperationResult.isSuccess - - override fun start() { - remoteOperation.execute( - engine.client, - { _, result -> - this.remoteOperationResult = result - if (isRemoteOperationSuccessful(result)) - onSuccessfulRemoteOperation(result) - else - onUnsuccessfulRemoteOperation(result) - }, - engine.listenerHandler - ) - } -} - -abstract class OperationTask(engine: NextCloudEngine) : BaseOperationTask(engine) { - override fun getResult() = OperationTaskResult(success, error, remoteOperationResult) -} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveFileTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveFileTask.kt deleted file mode 100644 index 4d2f7ad..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveFileTask.kt +++ /dev/null @@ -1,10 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import com.owncloud.android.lib.resources.files.RemoveFileRemoteOperation -import us.huseli.retain.nextcloud.NextCloudEngine - -/** Remove: 1 arbitrary file */ -open class RemoveFileTask(engine: NextCloudEngine, remotePath: String) : OperationTask(engine) { - override val remoteOperation = RemoveFileRemoteOperation(remotePath) - override val successMessageString = "Successfully removed $remotePath from Nextcloud" -} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveImageTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveImageTask.kt deleted file mode 100644 index 95e106e..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveImageTask.kt +++ /dev/null @@ -1,9 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import us.huseli.retain.Constants -import us.huseli.retain.data.entities.Image -import us.huseli.retain.nextcloud.NextCloudEngine - -/** Remove: 1 image file */ -class RemoveImageTask(engine: NextCloudEngine, image: Image) : - RemoveFileTask(engine, engine.getAbsolutePath(Constants.NEXTCLOUD_IMAGE_SUBDIR, image.filename)) diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveImagesTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveImagesTask.kt deleted file mode 100644 index 6615097..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveImagesTask.kt +++ /dev/null @@ -1,12 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import us.huseli.retain.data.entities.Image -import us.huseli.retain.nextcloud.NextCloudEngine - -/** Remove: 0..n image files */ -class RemoveImagesTask(engine: NextCloudEngine, images: Collection) : - ListTask(engine = engine, objects = images) { - override val failOnUnsuccessfulChildTask = false - - override fun getChildTask(obj: Image) = RemoveImageTask(engine = engine, image = obj) -} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveOrphanImagesTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveOrphanImagesTask.kt deleted file mode 100644 index 834c35c..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/RemoveOrphanImagesTask.kt +++ /dev/null @@ -1,29 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import com.owncloud.android.lib.resources.files.model.RemoteFile -import okhttp3.internal.toImmutableList -import us.huseli.retain.Constants -import us.huseli.retain.nextcloud.NextCloudEngine - -class RemoveOrphanImagesTask( - engine: NextCloudEngine, - private val keep: List -) : ListFilesListTask( - engine = engine, - remoteDir = engine.getAbsolutePath(Constants.NEXTCLOUD_IMAGE_SUBDIR), - filter = { remoteFile -> - remoteFile.mimeType != "DIR" && - !keep.contains(remoteFile.remotePath.split("/").last()) - }, -) { - override val failOnUnsuccessfulChildTask = false - - override fun getChildTask(remoteFile: RemoteFile) = RemoveFileTask(engine, remoteFile.remotePath) - - override fun getResult() = ListFilesTaskResult( - success = success, - error = error, - remoteOperationResult = remoteOperationResult, - remoteFiles = remoteFiles.toImmutableList(), - ) -} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/Task.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/Task.kt deleted file mode 100644 index 277d1ce..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/Task.kt +++ /dev/null @@ -1,85 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import android.util.Log -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import us.huseli.retain.LogInterface -import us.huseli.retain.LogMessage -import us.huseli.retain.Logger -import us.huseli.retain.nextcloud.NextCloudEngine - -open class TaskResult(open val success: Boolean, open val error: LogMessage? = null) - -abstract class BaseTask(protected val engine: NextCloudEngine) : LogInterface { - override val logger: Logger = engine.logger - - private var hasNotified = false - private var _status = MutableStateFlow(STATUS_WAITING) - private val onFinishedListeners = mutableListOf<(RT) -> Unit>() - - protected var success: Boolean = true - protected var error: LogMessage? = null - protected var triggerStatus: Int = NextCloudEngine.STATUS_OK - - open val startMessageString: String? = null - open val successMessageString: String? = null - open val isMetaTask: Boolean = false - - val status = _status.asStateFlow() - // val status: Int - // get() = _status - - abstract fun start() - abstract fun getResult(): RT - abstract fun isFinished(): Boolean - - fun addOnFinishedListener(listener: (RT) -> Unit) = onFinishedListeners.add(listener) - - open fun run(triggerStatus: Int = NextCloudEngine.STATUS_OK, onFinishedListener: ((RT) -> Unit)? = null) { - this.triggerStatus = triggerStatus - if (onFinishedListener != null) onFinishedListeners.add(onFinishedListener) - engine.registerTask(this, triggerStatus) { - _status.value = STATUS_RUNNING - log("${javaClass.simpleName}: START", level = Log.DEBUG) - startMessageString?.let { log(it) } - start() - notifyIfFinished() - } - } - - fun notifyIfFinished(successMessage: String? = null) { - if (isFinished() && !hasNotified) { - _status.value = STATUS_FINISHED - val result = getResult() - - hasNotified = true - if (success) { - (successMessage ?: successMessageString)?.let { log(it) } - log("${javaClass.simpleName}: FINISH SUCCESSFULLY", level = Log.DEBUG) - } else log("${javaClass.simpleName}: FINISH FAILINGLY", level = Log.ERROR) - - onFinishedListeners.forEach { it.invoke(result) } - } - } - - fun failWithMessage(logMessage: LogMessage?) { - success = false - error = logMessage ?: createLogMessage("Unknown error") - if (logMessage != null) log(logMessage) - notifyIfFinished() - } - - fun failWithMessage(message: String?) { - failWithMessage(logMessage = if (message != null) createLogMessage(message, level = Log.ERROR) else null) - } - - companion object { - const val STATUS_WAITING = 0 - const val STATUS_RUNNING = 1 - const val STATUS_FINISHED = 2 - } -} - -abstract class Task(engine: NextCloudEngine) : BaseTask(engine) { - override fun getResult() = TaskResult(success, error) -} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/TestNextCloudTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/TestNextCloudTask.kt deleted file mode 100644 index 7fb6c5b..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/TestNextCloudTask.kt +++ /dev/null @@ -1,94 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import android.content.Context -import android.os.Parcelable -import com.owncloud.android.lib.common.operations.RemoteOperationResult -import kotlinx.parcelize.Parcelize -import us.huseli.retain.Constants.NEXTCLOUD_IMAGE_SUBDIR -import us.huseli.retain.Constants.NEXTCLOUD_JSON_SUBDIR -import us.huseli.retain.LogMessage -import us.huseli.retain.R -import us.huseli.retain.nextcloud.NextCloudEngine -import java.net.UnknownHostException -import java.time.Instant - -@Parcelize -class TestNextCloudTaskResult( - override val success: Boolean, - override val error: LogMessage?, - val status: Int, - val isUrlFail: Boolean = false, - val isCredentialsFail: Boolean = false, - val resultCode: RemoteOperationResult.ResultCode? = null, - val resultErrorMessage: String? = null, - val timestamp: Instant = Instant.now(), -) : TaskResult(success, error), Parcelable { - override fun equals(other: Any?) = other is TestNextCloudTaskResult && other.timestamp == timestamp - - override fun hashCode() = timestamp.hashCode() - - fun getErrorMessage(context: Context): String { - val error = if (resultCode != null) { - when (resultCode) { - RemoteOperationResult.ResultCode.UNAUTHORIZED -> context.getString(R.string.server_reported_authorization_error) - RemoteOperationResult.ResultCode.FILE_NOT_FOUND -> context.getString(R.string.server_reported_file_not_found) - else -> resultErrorMessage - } - } else if (status == NextCloudEngine.STATUS_AUTH_ERROR) - context.getString(R.string.server_reported_authorization_error) - else - context.getString(R.string.unkown_error) - - return "${context.getString(R.string.failed_to_connect_to_nextcloud)}: $error" - } -} - - -class TestNextCloudTask(engine: NextCloudEngine) : - BaseListTask( - engine = engine, - objects = listOf(engine.getAbsolutePath(NEXTCLOUD_IMAGE_SUBDIR), engine.getAbsolutePath(NEXTCLOUD_JSON_SUBDIR)) - ) { - private var remoteOperationResult: RemoteOperationResult<*>? = null - - override fun getChildTask(obj: String) = CreateDirTask(engine, obj) - - override fun processChildTaskResult(obj: String, result: OperationTaskResult) { - // Only set this.remoteOperationResult if it is null or was successful, - // thereby giving priority to unsuccessful results: - result.remoteOperationResult?.let { - if (remoteOperationResult == null || remoteOperationResult?.isSuccess == true) { - remoteOperationResult = it - } - } - } - - override fun getResult(): TestNextCloudTaskResult { - val status = - if (success) NextCloudEngine.STATUS_OK - else if ( - remoteOperationResult?.code == RemoteOperationResult.ResultCode.UNAUTHORIZED || - remoteOperationResult?.code == RemoteOperationResult.ResultCode.FORBIDDEN - ) NextCloudEngine.STATUS_AUTH_ERROR - else NextCloudEngine.STATUS_ERROR - - val isUrlFail = - !success && - ( - remoteOperationResult?.exception is UnknownHostException || - remoteOperationResult?.code == RemoteOperationResult.ResultCode.FILE_NOT_FOUND - ) - - val isCredentialsFail = !success && remoteOperationResult?.code == RemoteOperationResult.ResultCode.UNAUTHORIZED - - return TestNextCloudTaskResult( - success = success, - error = error, - status = status, - isUrlFail = isUrlFail, - isCredentialsFail = isCredentialsFail, - resultCode = remoteOperationResult?.code, - resultErrorMessage = remoteOperationResult?.exception?.message ?: remoteOperationResult?.logMessage, - ) - } -} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadFileTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadFileTask.kt deleted file mode 100644 index 973d198..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadFileTask.kt +++ /dev/null @@ -1,27 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation -import us.huseli.retain.nextcloud.NextCloudEngine -import java.io.File - -/** Up: 1 arbitrary file */ -open class UploadFileTask( - engine: NextCloudEngine, - remotePath: String, - private val localFile: File, - mimeType: String?, -) : OperationTask(engine) { - override val successMessageString = "Successfully saved $localFile to $remotePath on Nextcloud" - - override val remoteOperation = UploadFileRemoteOperation( - localFile.absolutePath, - remotePath, - mimeType, - (System.currentTimeMillis() / 1000).toString() - ) - - override fun start() { - if (!localFile.isFile) failWithMessage("$localFile is not a file") - else super.start() - } -} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadImageTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadImageTask.kt deleted file mode 100644 index 25303e9..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadImageTask.kt +++ /dev/null @@ -1,14 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import us.huseli.retain.Constants -import us.huseli.retain.data.entities.Image -import us.huseli.retain.nextcloud.NextCloudEngine -import java.io.File - -/** Up: 1 image file */ -class UploadImageTask(engine: NextCloudEngine, image: Image) : UploadFileTask( - engine = engine, - remotePath = engine.getAbsolutePath(Constants.NEXTCLOUD_IMAGE_SUBDIR, image.filename), - localFile = File(File(engine.context.filesDir, Constants.IMAGE_SUBDIR), image.filename), - mimeType = image.mimeType -) \ No newline at end of file diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadNoteCombosTask.kt b/app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadNoteCombosTask.kt deleted file mode 100644 index 52d9b5c..0000000 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadNoteCombosTask.kt +++ /dev/null @@ -1,37 +0,0 @@ -package us.huseli.retain.nextcloud.tasks - -import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import us.huseli.retain.Constants -import us.huseli.retain.data.entities.NoteCombo -import us.huseli.retain.nextcloud.NextCloudEngine -import java.io.File -import java.io.FileWriter - -class UploadNoteCombosTask(engine: NextCloudEngine, private val combos: Collection) : OperationTask(engine) { - private val filename = "noteCombos.json" - private val remotePath = engine.getAbsolutePath(Constants.NEXTCLOUD_JSON_SUBDIR, filename) - private val localFile = File(engine.tempDirUp, filename).apply { deleteOnExit() } - - override val remoteOperation = UploadFileRemoteOperation( - localFile.absolutePath, - remotePath, - "application/json", - (System.currentTimeMillis() / 1000).toString() - ) - - override fun start() { - engine.ioScope.launch { - withContext(Dispatchers.IO) { - try { - FileWriter(localFile).use { it.write(engine.gson.toJson(combos)) } - super.start() - } catch (e: Exception) { - failWithMessage(e.toString()) - } - } - } - } -} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/Engine.kt b/app/src/main/java/us/huseli/retain/syncbackend/Engine.kt new file mode 100644 index 0000000..e850a90 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/Engine.kt @@ -0,0 +1,153 @@ +package us.huseli.retain.syncbackend + +import android.content.Context +import android.content.SharedPreferences +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.preference.PreferenceManager +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.launch +import us.huseli.retain.Enums.SyncBackend +import us.huseli.retain.InstantAdapter +import us.huseli.retain.LogInterface +import us.huseli.retain.syncbackend.tasks.OperationTaskResult +import us.huseli.retain.syncbackend.tasks.RemoteFile +import us.huseli.retain.syncbackend.tasks.Task +import us.huseli.retain.syncbackend.tasks.TaskResult +import us.huseli.retain.syncbackend.tasks.TestTask +import us.huseli.retain.syncbackend.tasks.TestTaskResult +import java.io.File +import java.time.Instant + +@OptIn(FlowPreview::class) +abstract class Engine(internal val context: Context, internal val ioScope: CoroutineScope) : LogInterface { + abstract val backend: SyncBackend + private var isTestScheduled = false + + protected val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + protected val tasks = MutableStateFlow>>(emptyList()) + protected var status = STATUS_DISABLED + + internal val gson: Gson = GsonBuilder() + .registerTypeAdapter(Instant::class.java, InstantAdapter()) + .create() + internal val tempDirUp = File(context.cacheDir, "up").apply { mkdirs() } + internal val tempDirDown = File(context.cacheDir, "down").apply { mkdirs() } + internal val listenerHandler = Handler(Looper.getMainLooper()) + + private val runningTasks: List> + get() = tasks.value.filter { it.status.value == Task.STATUS_RUNNING } + + private val runningNonMetaTasks: List> + get() = runningTasks.filter { !it.isMetaTask } + + private val waitingTasks: List> + get() = tasks.value.filter { it.status.value == Task.STATUS_WAITING } + + val hasActiveTasks = tasks.flatMapMerge { tasks -> + combine(*tasks.map { it.status }.toTypedArray()) { statuses -> + statuses.any { it != Task.STATUS_FINISHED } + } + } + + abstract fun removeFile(remotePath: String, onResult: (OperationTaskResult) -> Unit): Any + abstract fun createDir(remoteDir: String, onResult: (OperationTaskResult) -> Unit): Any + abstract fun downloadFile(remotePath: String, onResult: (OperationTaskResult) -> Unit): Any + + abstract fun listFiles( + remoteDir: String, + filter: ((RemoteFile) -> Boolean)? = null, + onResult: (OperationTaskResult) -> Unit + ): Any + + abstract fun uploadFile( + localFile: File, + remotePath: String, + mimeType: String?, + onResult: (OperationTaskResult) -> Unit + ): Any + + private fun logTasks() { + log("Running tasks: ${runningTasks.map { it.javaClass.simpleName }}") + log("Waiting tasks: ${waitingTasks.map { it.javaClass.simpleName }}") + } + + open fun getAbsolutePath(vararg segments: String) = segments.joinToString("/") { it.trim('/') } + + fun registerTask(task: Task<*, *>, triggerStatus: Int, callback: () -> Unit) { + log( + "registerTask: task=${task.javaClass.simpleName}, triggerStatus=$triggerStatus, status=$status", + level = Log.DEBUG + ) + tasks.value = tasks.value.toMutableList().apply { add(task) } + task.addOnFinishedListener { logTasks() } + if (status >= triggerStatus && runningNonMetaTasks.size < 3) callback() + else ioScope.launch { + while (status < triggerStatus || runningNonMetaTasks.size >= 3) delay(1_000) + callback() + } + logTasks() + } + + protected fun test(onResult: ((TestTaskResult) -> Unit)? = null) { + if (status == STATUS_TESTING) { + ioScope.launch { + while (status == STATUS_TESTING) delay(100) + test(onResult) + } + } else if (status == STATUS_DISABLED) { + ioScope.launch { + while (status == STATUS_DISABLED) delay(10_000) + test(onResult) + } + } else if (status < STATUS_AUTH_ERROR) { + // On auth error, don't even try anything until URL/username/PW has changed. + status = STATUS_TESTING + TestTask(this).run(STATUS_TESTING) { result -> + status = when (result.status) { + TaskResult.Status.OK -> STATUS_OK + TaskResult.Status.AUTH_ERROR -> STATUS_AUTH_ERROR + else -> STATUS_ERROR + } + onResult?.invoke(result) + + // Schedule low-frequency retries for as long as needed: + if (status < STATUS_AUTH_ERROR && !isTestScheduled) { + ioScope.launch { + isTestScheduled = true + while (status < STATUS_AUTH_ERROR) { + delay(30_000) + test() + } + isTestScheduled = false + } + } + } + } else onResult?.invoke( + TestTaskResult( + status = when (status) { + STATUS_OK -> TaskResult.Status.OK + STATUS_AUTH_ERROR -> TaskResult.Status.AUTH_ERROR + else -> TaskResult.Status.OTHER_ERROR + } + ) + ) + } + + companion object { + const val STATUS_DISABLED = 0 + const val STATUS_TESTING = 1 + const val STATUS_READY = 2 + const val STATUS_ERROR = 3 + const val STATUS_AUTH_ERROR = 4 + const val STATUS_OK = 5 + } +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/NextCloudEngine.kt b/app/src/main/java/us/huseli/retain/syncbackend/NextCloudEngine.kt new file mode 100644 index 0000000..ab159d4 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/NextCloudEngine.kt @@ -0,0 +1,244 @@ +package us.huseli.retain.syncbackend + +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri +import android.util.Log +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientFactory +import com.owncloud.android.lib.common.OwnCloudCredentialsFactory +import com.owncloud.android.lib.common.operations.RemoteOperation +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.files.CreateFolderRemoteOperation +import com.owncloud.android.lib.resources.files.DownloadFileRemoteOperation +import com.owncloud.android.lib.resources.files.ReadFolderRemoteOperation +import com.owncloud.android.lib.resources.files.RemoveFileRemoteOperation +import com.owncloud.android.lib.resources.files.UploadFileRemoteOperation +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import us.huseli.retain.Constants.NEXTCLOUD_BASE_DIR +import us.huseli.retain.Constants.PREF_NEXTCLOUD_BASE_DIR +import us.huseli.retain.Constants.PREF_NEXTCLOUD_PASSWORD +import us.huseli.retain.Constants.PREF_NEXTCLOUD_URI +import us.huseli.retain.Constants.PREF_NEXTCLOUD_USERNAME +import us.huseli.retain.Constants.PREF_SYNC_BACKEND +import us.huseli.retain.Enums.SyncBackend +import us.huseli.retain.Logger +import us.huseli.retain.syncbackend.tasks.OperationTaskResult +import us.huseli.retain.syncbackend.tasks.RemoteFile +import us.huseli.retain.syncbackend.tasks.TaskResult +import us.huseli.retain.syncbackend.tasks.TestTaskResult +import java.io.File +import java.net.UnknownHostException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NextCloudEngine @Inject constructor( + @ApplicationContext context: Context, + ioScope: CoroutineScope, + override val logger: Logger, +) : SharedPreferences.OnSharedPreferenceChangeListener, Engine(context, ioScope) { + override val backend: SyncBackend = SyncBackend.NEXTCLOUD + + private val _isTesting = MutableStateFlow(false) + + private var uri: Uri = Uri.EMPTY + set(value) { + if (field != value) { + field = value + updateClient(uri = value) + } + } + + private var username = "" + set(value) { + if (field != value) { + field = value + updateClient(username = value) + } + } + + private var password = "" + set(value) { + if (field != value) { + field = value + updateClient(password = value) + } + } + + private var baseDir = NEXTCLOUD_BASE_DIR + set(value) { + if (field != value.trimEnd('/')) field = value.trimEnd('/') + } + + private val client: OwnCloudClient = + OwnCloudClientFactory.createOwnCloudClient(uri, context, true).apply { + setDefaultTimeouts(120_000, 120_000) + } + + val isTesting = _isTesting.asStateFlow() + + init { + // These must be set here and not inline, because otherwise the set() + // methods are not run. + uri = Uri.parse(preferences.getString(PREF_NEXTCLOUD_URI, "") ?: "") + username = preferences.getString(PREF_NEXTCLOUD_USERNAME, "") ?: "" + password = preferences.getString(PREF_NEXTCLOUD_PASSWORD, "") ?: "" + baseDir = preferences.getString(PREF_NEXTCLOUD_BASE_DIR, NEXTCLOUD_BASE_DIR) ?: NEXTCLOUD_BASE_DIR + status = + if (preferences.getString(PREF_SYNC_BACKEND, null) == SyncBackend.NEXTCLOUD.name) STATUS_READY + else STATUS_DISABLED + preferences.registerOnSharedPreferenceChangeListener(this) + } + + private fun resultToStatus(result: RemoteOperationResult<*>): TaskResult.Status = + if (result.isSuccess) TaskResult.Status.OK + else if ( + result.code == RemoteOperationResult.ResultCode.UNAUTHORIZED || + result.code == RemoteOperationResult.ResultCode.FORBIDDEN + ) TaskResult.Status.AUTH_ERROR + else if (result.exception is UnknownHostException) TaskResult.Status.UNKNOWN_HOST + else if (result.code == RemoteOperationResult.ResultCode.FILE_NOT_FOUND) TaskResult.Status.CONNECT_ERROR + else TaskResult.Status.OTHER_ERROR + + private fun castResult( + result: RemoteOperationResult<*>, + status: TaskResult.Status? = null, + remoteFiles: List = emptyList(), + localFiles: List = emptyList(), + objects: List = emptyList(), + ): OperationTaskResult = OperationTaskResult( + status = status ?: resultToStatus(result), + exception = result.exception, + message = result.message ?: result.logMessage, + remoteFiles = remoteFiles, + localFiles = localFiles, + objects = objects, + ) + + private fun executeRemoteOperation( + operation: RemoteOperation<*>, + onResult: (RemoteOperationResult<*>) -> Unit + ) { + operation.execute(client, { _, result -> onResult(result) }, listenerHandler) + } + + private fun updateClient( + uri: Uri? = null, + username: String? = null, + password: String? = null, + isEnabled: Boolean? = null + ) { + log( + "updateClient: uri=$uri, username=$username, password=$password, isEnabled=$isEnabled", + level = Log.DEBUG + ) + if (uri != null) client.baseUri = uri + if (username != null || password != null) { + client.credentials = OwnCloudCredentialsFactory.newBasicCredentials( + username ?: this.username, + password ?: this.password + ) + if (username != null) client.userId = username + } + if (isEnabled == false) status = STATUS_DISABLED + else if (isEnabled == true || status != STATUS_DISABLED) status = STATUS_READY + } + + fun test( + uri: Uri, + username: String, + password: String, + baseDir: String, + onResult: ((TestTaskResult) -> Unit)? = null + ) { + this.uri = uri + this.username = username + this.password = password + this.baseDir = baseDir + if (this.uri.host != null) { + _isTesting.value = true + test { result -> + _isTesting.value = false + onResult?.invoke(result) + } + } + } + + override fun createDir(remoteDir: String, onResult: (OperationTaskResult) -> Unit) = + executeRemoteOperation(CreateFolderRemoteOperation(remoteDir, true)) { result -> + val status = + if (result.code == RemoteOperationResult.ResultCode.FOLDER_ALREADY_EXISTS) TaskResult.Status.OK + else resultToStatus(result) + onResult(castResult(result, status = status, objects = listOf(remoteDir))) + } + + override fun downloadFile(remotePath: String, onResult: (OperationTaskResult) -> Unit) = + executeRemoteOperation(DownloadFileRemoteOperation(remotePath, tempDirDown.absolutePath + '/')) { result -> + onResult( + castResult( + result, + localFiles = listOf(File(tempDirDown, remotePath)), + objects = listOf(remotePath) + ) + ) + } + + override fun getAbsolutePath(vararg segments: String) = + super.getAbsolutePath(baseDir.trimEnd('/'), *segments) + + @Suppress("DEPRECATION") + override fun listFiles( + remoteDir: String, + filter: ((RemoteFile) -> Boolean)?, + onResult: (OperationTaskResult) -> Unit + ) { + executeRemoteOperation(ReadFolderRemoteOperation(remoteDir)) { result -> + onResult( + castResult( + result = result, + remoteFiles = result.data + .filterIsInstance() + .map { RemoteFile(it.remotePath, it.length, it.mimeType == "DIR") } + .filter(filter ?: { true }) + ) + ) + } + } + + override fun removeFile(remotePath: String, onResult: (OperationTaskResult) -> Unit) = + executeRemoteOperation(RemoveFileRemoteOperation(remotePath)) { result -> + onResult(castResult(result, objects = listOf(remotePath))) + } + + override fun uploadFile( + localFile: File, + remotePath: String, + mimeType: String?, + onResult: (OperationTaskResult) -> Unit + ) = executeRemoteOperation( + UploadFileRemoteOperation( + localFile.absolutePath, + remotePath, + mimeType, + (System.currentTimeMillis() / 1000).toString() + ) + ) { result -> onResult(castResult(result, localFiles = listOf(localFile), objects = listOf(remotePath))) } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + PREF_NEXTCLOUD_URI -> uri = Uri.parse(preferences.getString(key, "") ?: "") + PREF_NEXTCLOUD_USERNAME -> username = preferences.getString(key, "") ?: "" + PREF_NEXTCLOUD_PASSWORD -> password = preferences.getString(key, "") ?: "" + PREF_NEXTCLOUD_BASE_DIR -> baseDir = preferences.getString(key, NEXTCLOUD_BASE_DIR) ?: NEXTCLOUD_BASE_DIR + PREF_SYNC_BACKEND -> { + if (preferences.getString(key, null) == SyncBackend.NEXTCLOUD.name) { + if (status == STATUS_DISABLED) status = STATUS_READY + } else status = STATUS_DISABLED + } + } + } +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/SFTPEngine.kt b/app/src/main/java/us/huseli/retain/syncbackend/SFTPEngine.kt new file mode 100644 index 0000000..aff6ee7 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/SFTPEngine.kt @@ -0,0 +1,233 @@ +package us.huseli.retain.syncbackend + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import com.jcraft.jsch.ChannelSftp +import com.jcraft.jsch.JSch +import com.jcraft.jsch.JSchException +import com.jcraft.jsch.SftpException +import com.jcraft.jsch.UserInfo +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import us.huseli.retain.Constants.PREF_SFTP_BASE_DIR +import us.huseli.retain.Constants.PREF_SFTP_HOSTNAME +import us.huseli.retain.Constants.PREF_SFTP_PASSWORD +import us.huseli.retain.Constants.PREF_SFTP_PORT +import us.huseli.retain.Constants.PREF_SFTP_USERNAME +import us.huseli.retain.Constants.PREF_SYNC_BACKEND +import us.huseli.retain.Constants.SFTP_BASE_DIR +import us.huseli.retain.Enums +import us.huseli.retain.Logger +import us.huseli.retain.syncbackend.tasks.OperationTaskResult +import us.huseli.retain.syncbackend.tasks.RemoteFile +import us.huseli.retain.syncbackend.tasks.TaskResult +import us.huseli.retain.syncbackend.tasks.TestTaskResult +import java.io.File +import java.net.ConnectException +import java.net.UnknownHostException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SFTPEngine @Inject constructor( + @ApplicationContext context: Context, + override val logger: Logger, + ioScope: CoroutineScope, +) : SharedPreferences.OnSharedPreferenceChangeListener, Engine(context, ioScope) { + override val backend: Enums.SyncBackend = Enums.SyncBackend.SFTP + private val jsch = JSch() + private val knownHostsFile = File(context.filesDir, "known_hosts") + private val _isTesting = MutableStateFlow(false) + + private val baseDir = MutableStateFlow(preferences.getString(PREF_SFTP_BASE_DIR, SFTP_BASE_DIR) ?: SFTP_BASE_DIR) + private val hostname = MutableStateFlow(preferences.getString(PREF_SFTP_HOSTNAME, "") ?: "") + private val password = MutableStateFlow(preferences.getString(PREF_SFTP_PASSWORD, "") ?: "") + private val port = MutableStateFlow(preferences.getInt(PREF_SFTP_PORT, 22)) + private val username = MutableStateFlow(preferences.getString(PREF_SFTP_USERNAME, "") ?: "") + private val isKeyApproved = MutableStateFlow(false) + + val promptYesNo = MutableStateFlow(null) + val isTesting = _isTesting.asStateFlow() + + private val userInfo = object : UserInfo { + override fun getPassphrase() = null + override fun getPassword() = this@SFTPEngine.password.value + override fun promptPassword(message: String?) = false + override fun promptPassphrase(message: String?) = false + override fun showMessage(message: String?) = Unit + + override fun promptYesNo(message: String?): Boolean { + log("promptYesNo: message=$message") + if (!isKeyApproved.value) promptYesNo.value = message + return isKeyApproved.value + } + } + + init { + status = + if (preferences.getString(PREF_SYNC_BACKEND, null) == Enums.SyncBackend.SFTP.name) STATUS_READY + else STATUS_DISABLED + preferences.registerOnSharedPreferenceChangeListener(this) + jsch.setKnownHosts(knownHostsFile.absolutePath) + } + + fun approveKey() { + isKeyApproved.value = true + promptYesNo.value = null + } + + fun denyKey() { + isKeyApproved.value = false + promptYesNo.value = null + } + + private fun exceptionToResult(exception: Exception, objects: List = emptyList()): OperationTaskResult { + val status = + if (exception is JSchException) { + when (exception.cause) { + is UnknownHostException -> TaskResult.Status.UNKNOWN_HOST + is ConnectException -> TaskResult.Status.CONNECT_ERROR + else -> TaskResult.Status.AUTH_ERROR + } + } else TaskResult.Status.OTHER_ERROR + return OperationTaskResult(status = status, exception = exception, objects = objects) + } + + private fun runCommand( + onResult: ((OperationTaskResult) -> Unit)? = null, + command: ChannelSftp.() -> Unit, + ) { + val session = jsch.getSession(username.value, hostname.value, port.value).apply { setPassword(password.value) } + session.userInfo = userInfo + + try { + session.connect() + val channel = session.openChannel("sftp") as ChannelSftp + channel.connect() + channel.apply { command() } + session.disconnect() + if (onResult != null) onResult(OperationTaskResult(status = TaskResult.Status.OK)) + } catch (e: Exception) { + log("runCommand: error=$e, session=$session", level = Log.ERROR) + if (onResult != null) onResult(exceptionToResult(e)) + else throw e + } + } + + fun test( + hostname: String, + username: String, + password: String, + baseDir: String, + onResult: ((TestTaskResult) -> Unit)? = null + ) { + this.hostname.value = hostname + this.username.value = username + this.password.value = password + this.baseDir.value = baseDir + if (this.hostname.value.isNotEmpty() && this.username.value.isNotEmpty() && this.password.value.isNotEmpty()) { + _isTesting.value = true + test { result -> + _isTesting.value = false + onResult?.invoke(result) + } + } + } + + override fun createDir(remoteDir: String, onResult: (OperationTaskResult) -> Unit) = ioScope.launch { + runCommand( + command = { + val dirs = remoteDir.split('/') + dirs.forEachIndexed { index, _ -> + try { + mkdir(dirs.subList(0, index + 1).joinToString("/")) + } catch (e: SftpException) { + // It seems like id==4 means the directory already exists: + if (e.id != 4) throw e + } + } + }, + onResult = { result -> onResult(result.copy(objects = listOf(remoteDir))) }, + ) + } + + override fun downloadFile(remotePath: String, onResult: (OperationTaskResult) -> Unit) = ioScope.launch { + runCommand( + command = { get(remotePath, tempDirDown.absolutePath) }, + onResult = { result -> + onResult( + result.copy( + localFiles = listOf(File(tempDirDown, remotePath.split('/').last())), + objects = listOf(remotePath) + ) + ) + }, + ) + } + + override fun getAbsolutePath(vararg segments: String) = + super.getAbsolutePath(baseDir.value.trimEnd('/'), *segments) + + override fun listFiles( + remoteDir: String, + filter: ((RemoteFile) -> Boolean)?, + onResult: (OperationTaskResult) -> Unit + ) = ioScope.launch { + try { + runCommand { + val lsResult = ls(remoteDir) + onResult( + OperationTaskResult( + status = TaskResult.Status.OK, + remoteFiles = lsResult + .map { entry -> RemoteFile(entry.filename, entry.attrs.size, entry.attrs.isDir) } + .filter(filter ?: { true }), + objects = lsResult, + ) + ) + } + } catch (e: Exception) { + onResult(exceptionToResult(e, objects = listOf(remoteDir))) + } + } + + override fun removeFile(remotePath: String, onResult: (OperationTaskResult) -> Unit) = ioScope.launch { + runCommand( + command = { rm(remotePath) }, + onResult = { result -> onResult(result.copy(objects = listOf(remotePath))) }, + ) + } + + override fun uploadFile( + localFile: File, + remotePath: String, + mimeType: String?, + onResult: (OperationTaskResult) -> Unit + ) = ioScope.launch { + runCommand( + command = { put(localFile.absolutePath, remotePath) }, + onResult = { result -> + onResult(result.copy(localFiles = listOf(localFile), objects = listOf(remotePath))) + }, + ) + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + PREF_SFTP_BASE_DIR -> baseDir.value = preferences.getString(key, SFTP_BASE_DIR) ?: SFTP_BASE_DIR + PREF_SFTP_HOSTNAME -> hostname.value = preferences.getString(key, "") ?: "" + PREF_SFTP_PASSWORD -> password.value = preferences.getString(key, "") ?: "" + PREF_SFTP_PORT -> port.value = preferences.getInt(key, 22) + PREF_SFTP_USERNAME -> username.value = preferences.getString(key, "") ?: "" + PREF_SYNC_BACKEND -> { + if (preferences.getString(key, null) == Enums.SyncBackend.SFTP.name) { + if (status == STATUS_DISABLED) status = STATUS_READY + } else status = STATUS_DISABLED + } + } + } +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/CreateDirTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/CreateDirTask.kt new file mode 100644 index 0000000..ad53563 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/CreateDirTask.kt @@ -0,0 +1,8 @@ +package us.huseli.retain.syncbackend.tasks + +import us.huseli.retain.syncbackend.Engine + +class CreateDirTask(engine: ET, private val remoteDir: String) : + OperationTask(engine) { + override fun start(onResult: (OperationTaskResult) -> Unit) = engine.createDir(remoteDir, onResult) +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadFileTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadFileTask.kt new file mode 100644 index 0000000..89b3001 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadFileTask.kt @@ -0,0 +1,24 @@ +package us.huseli.retain.syncbackend.tasks + +import us.huseli.retain.syncbackend.Engine +import java.io.File + +/** Down: 1 arbitrary file */ +abstract class DownloadFileTask( + engine: ET, + protected val remotePath: String, +) : OperationTask(engine) { + override val successMessageString = "Successfully downloaded $remotePath" + + override fun start(onResult: (RT) -> Unit) { + engine.downloadFile(remotePath) { result -> + val localFile = result.localFiles.getOrNull(0) + + @Suppress("UNCHECKED_CAST") + if (localFile != null) handleDownloadedFile(localFile, result, onResult) + else onResult(result as RT) + } + } + + abstract fun handleDownloadedFile(file: File, result: OperationTaskResult, onResult: (RT) -> Unit) +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadImagesTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadImagesTask.kt new file mode 100644 index 0000000..2ac4ba7 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadImagesTask.kt @@ -0,0 +1,41 @@ +package us.huseli.retain.syncbackend.tasks + +import us.huseli.retain.Constants.SYNCBACKEND_IMAGE_SUBDIR +import us.huseli.retain.data.entities.Image +import us.huseli.retain.syncbackend.Engine +import java.io.File + +class DownloadImageTask(engine: ET, remotePath: String, private val localFile: File) : + DownloadFileTask(engine, remotePath) { + override fun handleDownloadedFile( + file: File, + result: OperationTaskResult, + onResult: (OperationTaskResult) -> Unit + ) { + file.renameTo(localFile) + onResult(result.copy(localFiles = listOf(localFile))) + } +} + +/** Down: 0..n images */ +class DownloadImagesTask( + engine: ET, + images: Collection +) : ListTask, Image>( + engine = engine, + objects = images +) { + override fun getResultForEmptyList() = TaskResult(status = TaskResult.Status.OK) + + override fun processChildTaskResult(obj: Image, result: OperationTaskResult, onResult: (TaskResult) -> Unit) { + if (successfulObjects.size + unsuccessfulObjects.size == objects.size) { + onResult(TaskResult(status = TaskResult.Status.OK)) + } + } + + override fun getChildTask(obj: Image) = DownloadImageTask( + engine = engine, + remotePath = engine.getAbsolutePath(SYNCBACKEND_IMAGE_SUBDIR, obj.filename), + localFile = File(File(engine.context.filesDir, "images"), obj.filename), + ) +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadListJSONTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadListJSONTask.kt new file mode 100644 index 0000000..1742f03 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadListJSONTask.kt @@ -0,0 +1,79 @@ +package us.huseli.retain.syncbackend.tasks + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import us.huseli.retain.syncbackend.Engine +import java.io.File +import java.io.FileReader + +class DownloadListJSONTaskResult( + status: Status, + message: String? = null, + exception: Exception? = null, + remoteFiles: List = emptyList(), + override val objects: List = emptyList(), +) : OperationTaskResult(status, message, exception, remoteFiles, objects = objects) + + +abstract class DownloadListJSONTask( + engine: ET, + remotePath: String +) : DownloadFileTask>( + engine = engine, + remotePath = remotePath, +) { + private var _finished = false + + abstract fun deserialize(json: String): List? + + @Suppress("SameParameterValue") + private fun castResult( + result: OperationTaskResult, + status: TaskResult.Status = result.status, + exception: Exception? = result.exception, + message: String? = exception?.message ?: result.message, + objects: List = emptyList(), + ) = DownloadListJSONTaskResult( + status = status, + message = message, + exception = exception, + remoteFiles = result.remoteFiles, + objects = objects + ) + + override fun handleDownloadedFile( + file: File, + result: OperationTaskResult, + onResult: (DownloadListJSONTaskResult) -> Unit + ) { + if (!result.success) { + onResult(castResult(result)) + } else engine.ioScope.launch { + withContext(Dispatchers.IO) { + try { + val json = FileReader(file).use { it.readText() } + val objects = deserialize(json) + if (objects != null) onResult(castResult(result, objects = objects)) + else onResult( + castResult( + result = result, + status = TaskResult.Status.OTHER_ERROR, + message = "$remotePath: result is null", + ) + ) + } catch (e: Exception) { + onResult( + castResult( + result = result, + status = TaskResult.Status.OTHER_ERROR, + exception = e + ) + ) + } finally { + file.delete() + } + } + } + } +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadNoteCombosJSONTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadNoteCombosJSONTask.kt new file mode 100644 index 0000000..f6f4060 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadNoteCombosJSONTask.kt @@ -0,0 +1,23 @@ +package us.huseli.retain.syncbackend.tasks + +import com.google.gson.reflect.TypeToken +import us.huseli.retain.Constants.SYNCBACKEND_JSON_SUBDIR +import us.huseli.retain.data.entities.NoteCombo +import us.huseli.retain.syncbackend.Engine +import java.util.UUID + +class DownloadNoteCombosJSONTask( + engine: ET, + private val deletedNoteIds: Collection +) : DownloadListJSONTask( + engine = engine, + remotePath = engine.getAbsolutePath(SYNCBACKEND_JSON_SUBDIR, "noteCombos.json") +) { + override fun deserialize(json: String): List? { + val listType = object : TypeToken>() {} + @Suppress("RemoveExplicitTypeArguments") + return engine.gson.fromJson>(json, listType)?.filter { + !deletedNoteIds.contains(it.note.id) + } + } +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/ListFilesListTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/ListFilesListTask.kt new file mode 100644 index 0000000..173ac47 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/ListFilesListTask.kt @@ -0,0 +1,48 @@ +package us.huseli.retain.syncbackend.tasks + +import us.huseli.retain.syncbackend.Engine +import us.huseli.retain.syncbackend.Engine.Companion.STATUS_OK + +abstract class ListFilesListTask>( + engine: ET, + remoteDir: String, + filter: (RemoteFile) -> Boolean +) : ListFilesTask(engine, remoteDir, filter) { + private var onEachCallback: ((RemoteFile, CRT) -> Unit)? = null + protected val successfulRemoteFiles = mutableListOf() + protected val unsuccessfulRemoteFiles = mutableListOf() + protected open val failOnUnsuccessfulChildTask = true + override val isMetaTask = true + + abstract fun getChildTask(remoteFile: RemoteFile): CT? + abstract fun processChildTaskResult( + remoteFile: RemoteFile, + result: OperationTaskResult, + childResult: CRT, + onResult: (OperationTaskResult) -> Unit + ) + + override fun start(onResult: (OperationTaskResult) -> Unit) { + super.start { result -> + if (result.remoteFiles.isNotEmpty()) { + result.remoteFiles.forEach { remoteFile -> + getChildTask(remoteFile)?.run { childResult -> + if (childResult.success) successfulRemoteFiles.add(remoteFile) + else unsuccessfulRemoteFiles.add(remoteFile) + processChildTaskResult(remoteFile, result, childResult, onResult) + onEachCallback?.invoke(remoteFile, childResult) + } + } + } else onResult(getResultForEmptyList()) + } + } + + fun run( + triggerStatus: Int = STATUS_OK, + onEachCallback: ((RemoteFile, CRT) -> Unit)?, + onReadyCallback: ((OperationTaskResult) -> Unit)? + ) { + this.onEachCallback = onEachCallback + super.run(triggerStatus, onReadyCallback) + } +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/ListFilesTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/ListFilesTask.kt new file mode 100644 index 0000000..0a74158 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/ListFilesTask.kt @@ -0,0 +1,16 @@ +package us.huseli.retain.syncbackend.tasks + +import us.huseli.retain.syncbackend.Engine + +/** List: arbitrary files */ +abstract class ListFilesTask( + engine: ET, + private val remoteDir: String, + protected val filter: ((RemoteFile) -> Boolean)? = null, +) : OperationTask(engine) { + protected val remoteFiles = mutableListOf() + + abstract fun getResultForEmptyList(): OperationTaskResult + + override fun start(onResult: (OperationTaskResult) -> Unit) = engine.listFiles(remoteDir, filter, onResult) +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/ListTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/ListTask.kt new file mode 100644 index 0000000..81836dd --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/ListTask.kt @@ -0,0 +1,54 @@ +package us.huseli.retain.syncbackend.tasks + +import us.huseli.retain.syncbackend.Engine +import us.huseli.retain.syncbackend.Engine.Companion.STATUS_OK + +/** + * Default behaviour: Fail immediately when any child task fails. If this is + * not desired, override isReady() and/or onUnsuccessfulChildTask(). + * + * By default, this.error will be the same as the error from the latest failed + * child task, which is not super optimal, but I guess we can live with it. + */ +abstract class BaseListTask, LT>( + engine: ET, + protected val objects: Collection, +) : Task(engine) { + private var onEachCallback: ((LT, CRT) -> Unit)? = null + protected val successfulObjects = mutableListOf() + protected val unsuccessfulObjects = mutableListOf() + protected open val failOnUnsuccessfulChildTask = true + override val isMetaTask = true + + abstract fun getChildTask(obj: LT): CT? + abstract fun processChildTaskResult(obj: LT, result: CRT, onResult: (RT) -> Unit) + abstract fun getResultForEmptyList(): RT + + fun run( + triggerStatus: Int = STATUS_OK, + onEachCallback: ((LT, CRT) -> Unit)?, + onReadyCallback: ((RT) -> Unit)? + ) { + this.onEachCallback = onEachCallback + super.run(triggerStatus, onReadyCallback) + } + + override fun start(onResult: (RT) -> Unit) { + if (objects.isNotEmpty()) { + objects.forEach { obj -> + getChildTask(obj)?.run(triggerStatus) { childResult -> + if (childResult.success) successfulObjects.add(obj) + else unsuccessfulObjects.add(obj) + processChildTaskResult(obj, childResult, onResult) + onEachCallback?.invoke(obj, childResult) + } + } + } else onResult(getResultForEmptyList()) + } +} + + +abstract class ListTask, LT>( + engine: ET, + objects: Collection +) : BaseListTask(engine, objects) diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/OperationTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/OperationTask.kt new file mode 100644 index 0000000..a5d0cc4 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/OperationTask.kt @@ -0,0 +1,27 @@ +package us.huseli.retain.syncbackend.tasks + +import us.huseli.retain.syncbackend.Engine +import java.io.File + +data class RemoteFile(val name: String, val size: Long, val isDirectory: Boolean) + +open class OperationTaskResult( + status: Status, + message: String? = null, + exception: Exception? = null, + val remoteFiles: List = emptyList(), + val localFiles: List = emptyList(), + open val objects: List = emptyList(), +) : TaskResult(status, message, exception) { + fun copy( + status: Status = this.status, + message: String? = this.message, + exception: Exception? = this.exception, + remoteFiles: List = this.remoteFiles, + localFiles: List = this.localFiles, + objects: List = this.objects, + ) = OperationTaskResult(status, message, exception, remoteFiles, localFiles, objects) +} + + +abstract class OperationTask(engine: ET) : Task(engine) diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveFileTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveFileTask.kt new file mode 100644 index 0000000..4e3a43d --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveFileTask.kt @@ -0,0 +1,10 @@ +package us.huseli.retain.syncbackend.tasks + +import us.huseli.retain.syncbackend.Engine + +/** Remove: 1 arbitrary file */ +open class RemoveFileTask(engine: ET, private val remotePath: String) : + OperationTask(engine) { + override val successMessageString = "Successfully removed $remotePath from Nextcloud" + override fun start(onResult: (OperationTaskResult) -> Unit) = engine.removeFile(remotePath, onResult) +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveImagesTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveImagesTask.kt new file mode 100644 index 0000000..4e2422b --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveImagesTask.kt @@ -0,0 +1,22 @@ +package us.huseli.retain.syncbackend.tasks + +import us.huseli.retain.Constants +import us.huseli.retain.data.entities.Image +import us.huseli.retain.syncbackend.Engine + +/** Remove: 0..n image files */ +class RemoveImagesTask(engine: ET, images: Collection) : + ListTask, Image>(engine = engine, objects = images) { + override val failOnUnsuccessfulChildTask = false + override fun getResultForEmptyList() = TaskResult(status = TaskResult.Status.OK) + + override fun processChildTaskResult(obj: Image, result: OperationTaskResult, onResult: (TaskResult) -> Unit) { + if (successfulObjects.size + unsuccessfulObjects.size == objects.size) + onResult(TaskResult(status = TaskResult.Status.OK)) + } + + override fun getChildTask(obj: Image) = RemoveFileTask( + engine = engine, + remotePath = engine.getAbsolutePath(Constants.SYNCBACKEND_IMAGE_SUBDIR, obj.filename), + ) +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveOrphanImagesTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveOrphanImagesTask.kt new file mode 100644 index 0000000..03224c6 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveOrphanImagesTask.kt @@ -0,0 +1,26 @@ +package us.huseli.retain.syncbackend.tasks + +import us.huseli.retain.Constants +import us.huseli.retain.syncbackend.Engine + +class RemoveOrphanImagesTask(engine: ET, private val keep: List) : + ListFilesListTask>( + engine = engine, + remoteDir = engine.getAbsolutePath(Constants.SYNCBACKEND_IMAGE_SUBDIR), + filter = { (name, _, isDirectory) -> !isDirectory && !keep.contains(name.split("/").last()) }, + ) { + override val failOnUnsuccessfulChildTask = false + + override fun getChildTask(remoteFile: RemoteFile) = RemoveFileTask(engine, remoteFile.name) + + override fun processChildTaskResult( + remoteFile: RemoteFile, + result: OperationTaskResult, + childResult: OperationTaskResult, + onResult: (OperationTaskResult) -> Unit + ) { + if (successfulRemoteFiles.size + unsuccessfulRemoteFiles.size == result.remoteFiles.size) onResult(result) + } + + override fun getResultForEmptyList() = OperationTaskResult(status = TaskResult.Status.OK) +} diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/SyncTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/SyncTask.kt similarity index 77% rename from app/src/main/java/us/huseli/retain/nextcloud/tasks/SyncTask.kt rename to app/src/main/java/us/huseli/retain/syncbackend/tasks/SyncTask.kt index bddf096..5ef4487 100644 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/SyncTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/SyncTask.kt @@ -1,17 +1,17 @@ -package us.huseli.retain.nextcloud.tasks +package us.huseli.retain.syncbackend.tasks import us.huseli.retain.data.entities.NoteCombo -import us.huseli.retain.nextcloud.NextCloudEngine +import us.huseli.retain.syncbackend.Engine import java.io.File import java.util.UUID -class SyncTask( - engine: NextCloudEngine, +class SyncTask( + engine: ET, private val localCombos: Collection, private val deletedNoteIds: Collection, private val onRemoteComboUpdated: (NoteCombo) -> Unit, private val localImageDir: File, -) : BaseTask(engine = engine) { +) : Task(engine = engine) { private val images = mutableListOf(*localCombos.map { it.images }.flatten().toTypedArray()) private var downloadNoteCombosJSONTaskFinished = false private var downloadMissingImagesTaskFinished = false @@ -21,9 +21,7 @@ class SyncTask( private var removeOrphanImagesTaskFinished = false private var remoteUpdatedCombos: List = emptyList() - override fun getResult() = TaskResult(success, error) - - override fun isFinished() = + private fun isFinished() = downloadNoteImagesTasksFinished == remoteUpdatedCombos.size && downloadNoteCombosJSONTaskFinished && uploadNoteCombosTaskFinished && @@ -31,13 +29,18 @@ class SyncTask( removeOrphanImagesTaskFinished && downloadMissingImagesTaskFinished - override fun start() { - DownloadMissingImagesTask(engine, images.filter { !File(localImageDir, it.filename).exists() }).run { + private fun notifyIfFinished(onResult: (TaskResult) -> Unit) { + if (isFinished()) onResult(TaskResult(status = TaskResult.Status.OK)) + } + + override fun start(onResult: (TaskResult) -> Unit) { + DownloadImagesTask(engine, images.filter { !File(localImageDir, it.filename).exists() }).run { downloadMissingImagesTaskFinished = true + notifyIfFinished(onResult) } DownloadNoteCombosJSONTask(engine, deletedNoteIds).run { downTaskResult -> - val remoteCombos = downTaskResult.objects ?: emptyList() + val remoteCombos = downTaskResult.objects downloadNoteCombosJSONTaskFinished = true // All notes on remote that either don't exist locally, or @@ -53,13 +56,13 @@ class SyncTask( remoteUpdatedCombos.forEach { combo -> images.addAll(combo.images) onRemoteComboUpdated(combo) - DownloadNoteImagesTask(engine, combo).run( + DownloadImagesTask(engine, combo.images).run( onEachCallback = { _, result -> - if (!result.success) logError("Failed to download image from Nextcloud", result.error) + if (!result.success) logError("Failed to download image: ${result.message}") }, onReadyCallback = { downloadNoteImagesTasksFinished++ - notifyIfFinished() + notifyIfFinished(onResult) } ) } @@ -74,18 +77,18 @@ class SyncTask( // Now upload all notes (i.e. the pre-existing local notes joined with the updated remote ones): val combos = localCombos.toSet().union(remoteUpdatedCombos.toSet()) UploadNoteCombosTask(engine, combos).run { result -> - if (!result.success) logError("Failed to upload notes to Nextcloud", result.error) + if (!result.success) logError("Failed to upload notes: ${result.message}") uploadNoteCombosTaskFinished = true - notifyIfFinished() + notifyIfFinished(onResult) } // Upload any images that are missing/wrong on remote: val imageFilenames = images.map { it.filename } UploadMissingImagesTask(engine, images).run { result -> - if (!result.success) logError("Failed to upload image to Nextcloud", result.error) + if (!result.success) logError("Failed to upload image: ${result.message}") uploadMissingImagesTaskFinished = true - notifyIfFinished() + notifyIfFinished(onResult) } // Delete any orphan image files, both locally and on Nextcloud: @@ -94,8 +97,10 @@ class SyncTask( } RemoveOrphanImagesTask(engine, keep = imageFilenames).run { removeOrphanImagesTaskFinished = true - notifyIfFinished() + notifyIfFinished(onResult) } + + notifyIfFinished(onResult) } } } diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/Task.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/Task.kt new file mode 100644 index 0000000..57873dc --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/Task.kt @@ -0,0 +1,72 @@ +package us.huseli.retain.syncbackend.tasks + +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import us.huseli.retain.LogInterface +import us.huseli.retain.Logger +import us.huseli.retain.syncbackend.Engine +import us.huseli.retain.syncbackend.Engine.Companion.STATUS_OK + +open class TaskResult( + open val status: Status, + open val message: String? = null, + open val exception: Exception? = null, +) { + enum class Status { OK, UNKNOWN_HOST, CONNECT_ERROR, AUTH_ERROR, OTHER_ERROR } + + val success + get() = status == Status.OK + val hasNetworkError + get() = listOf(Status.CONNECT_ERROR, Status.AUTH_ERROR, Status.UNKNOWN_HOST).contains(status) + + fun copy(status: Status = this.status, message: String? = this.message, exception: Exception? = this.exception) = + TaskResult(status, message, exception) +} + +abstract class Task(protected val engine: ET) : LogInterface { + override val logger: Logger = engine.logger + + private var _status = MutableStateFlow(STATUS_WAITING) + private val onFinishedListeners = mutableListOf<(RT) -> Unit>() + + protected var triggerStatus: Int = STATUS_OK + + open val startMessageString: String? = null + open val successMessageString: String? = null + open val isMetaTask: Boolean = false + + val status = _status.asStateFlow() + + abstract fun start(onResult: (RT) -> Unit): Any + + fun addOnFinishedListener(listener: (RT) -> Unit) = onFinishedListeners.add(listener) + + open fun run(triggerStatus: Int = STATUS_OK, onFinishedListener: ((RT) -> Unit)? = null) { + this.triggerStatus = triggerStatus + if (onFinishedListener != null) onFinishedListeners.add(onFinishedListener) + + engine.registerTask(this, triggerStatus) { + _status.value = STATUS_RUNNING + log("${javaClass.simpleName}: START", level = Log.DEBUG) + startMessageString?.let { log(it) } + start { result -> + _status.value = STATUS_FINISHED + if (result.success) { + successMessageString?.let { log(it) } + log("${javaClass.simpleName}: FINISH SUCCESSFULLY", level = Log.DEBUG) + } else { + result.message?.let { log(it) } + log("${javaClass.simpleName}: FINISH FAILINGLY", level = Log.ERROR) + } + onFinishedListeners.forEach { it.invoke(result) } + } + } + } + + companion object { + const val STATUS_WAITING = 0 + const val STATUS_RUNNING = 1 + const val STATUS_FINISHED = 2 + } +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/TestTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/TestTask.kt new file mode 100644 index 0000000..8c6cea6 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/TestTask.kt @@ -0,0 +1,55 @@ +package us.huseli.retain.syncbackend.tasks + +import android.content.Context +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import us.huseli.retain.Constants.SYNCBACKEND_IMAGE_SUBDIR +import us.huseli.retain.Constants.SYNCBACKEND_JSON_SUBDIR +import us.huseli.retain.R +import us.huseli.retain.syncbackend.Engine +import java.time.Instant + +@Parcelize +class TestTaskResult( + override val status: Status, + override val message: String? = null, + override val exception: Exception? = null, + val timestamp: Instant = Instant.now(), +) : TaskResult(status, message, exception), Parcelable { + override fun equals(other: Any?) = other is TestTaskResult && other.timestamp == timestamp + + override fun hashCode() = timestamp.hashCode() + + fun getErrorMessage(context: Context): String { + return when (status) { + Status.UNKNOWN_HOST -> context.getString(R.string.unknown_host) + Status.AUTH_ERROR -> context.getString(R.string.server_reported_authorization_error) + Status.CONNECT_ERROR -> context.getString(R.string.connect_error) + else -> context.getString(R.string.unkown_error) + } + } + + companion object { + fun fromTaskResult(result: TaskResult) = + TestTaskResult(status = result.status, message = result.message, exception = result.exception) + } +} + + +class TestTask(engine: ET) : + BaseListTask, String>( + engine = engine, + objects = listOf( + engine.getAbsolutePath(SYNCBACKEND_IMAGE_SUBDIR), + engine.getAbsolutePath(SYNCBACKEND_JSON_SUBDIR) + ) + ) { + + override fun getChildTask(obj: String) = CreateDirTask(engine, obj) + + override fun processChildTaskResult(obj: String, result: OperationTaskResult, onResult: (TestTaskResult) -> Unit) { + if (!result.success || successfulObjects.size == objects.size) onResult(TestTaskResult.fromTaskResult(result)) + } + + override fun getResultForEmptyList() = TestTaskResult(status = TaskResult.Status.OK) +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadFileTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadFileTask.kt new file mode 100644 index 0000000..5d117b9 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadFileTask.kt @@ -0,0 +1,21 @@ +package us.huseli.retain.syncbackend.tasks + +import us.huseli.retain.syncbackend.Engine +import java.io.File + +/** Up: 1 arbitrary file */ +open class UploadFileTask( + engine: ET, + private val remotePath: String, + private val localFile: File, + private val mimeType: String? = null, +) : OperationTask(engine) { + override val successMessageString = "Successfully saved $localFile to $remotePath on Nextcloud" + + override fun start(onResult: (OperationTaskResult) -> Unit) { + if (!localFile.isFile) + onResult(OperationTaskResult(status = TaskResult.Status.OTHER_ERROR, message = "$localFile is not a file")) + else + engine.uploadFile(localFile, remotePath, mimeType, onResult) + } +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadImageTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadImageTask.kt new file mode 100644 index 0000000..b7a4f24 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadImageTask.kt @@ -0,0 +1,13 @@ +package us.huseli.retain.syncbackend.tasks + +import us.huseli.retain.Constants +import us.huseli.retain.data.entities.Image +import us.huseli.retain.syncbackend.Engine +import java.io.File + +/** Up: 1 image file */ +class UploadImageTask(engine: ET, image: Image) : UploadFileTask( + engine = engine, + remotePath = engine.getAbsolutePath(Constants.SYNCBACKEND_IMAGE_SUBDIR, image.filename), + localFile = File(File(engine.context.filesDir, Constants.IMAGE_SUBDIR), image.filename), +) diff --git a/app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadMissingImagesTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadMissingImagesTask.kt similarity index 59% rename from app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadMissingImagesTask.kt rename to app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadMissingImagesTask.kt index 010be77..838ef5e 100644 --- a/app/src/main/java/us/huseli/retain/nextcloud/tasks/UploadMissingImagesTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadMissingImagesTask.kt @@ -1,49 +1,52 @@ -package us.huseli.retain.nextcloud.tasks +package us.huseli.retain.syncbackend.tasks import us.huseli.retain.Constants import us.huseli.retain.data.entities.Image -import us.huseli.retain.nextcloud.NextCloudEngine +import us.huseli.retain.syncbackend.Engine /** * Up: 0..n image files * Cannot extend ListTask, because this one iterates over a subset of images, * and that subset isn't known until ListFilesTask has been run. */ -class UploadMissingImagesTask(engine: NextCloudEngine, private val images: Collection) : Task(engine) { +class UploadMissingImagesTask( + engine: ET, + private val images: Collection +) : ListFilesTask( + engine, + engine.getAbsolutePath(Constants.SYNCBACKEND_IMAGE_SUBDIR) +) { private var processedFiles = 0 private var missingImages = mutableListOf() - override fun isFinished() = !success || missingImages.size == processedFiles + override fun getResultForEmptyList() = OperationTaskResult(status = TaskResult.Status.OK) + override val isMetaTask = true - override fun start() { - ListFilesTask( - engine = engine, - remoteDir = engine.getAbsolutePath(Constants.NEXTCLOUD_IMAGE_SUBDIR), - filter = { remoteFile -> remoteFile.mimeType != "DIR" }, - ).run(triggerStatus) { result -> + override fun start(onResult: (OperationTaskResult) -> Unit) { + super.start { result -> missingImages.addAll( if (result.success) { // First list current images and their sizes: val remoteImageLengths = result.remoteFiles.associate { - Pair(it.remotePath.split("/").last(), it.length.toInt()) + it.name.split('/').last() to it.size } // Then filter DB images for those where the corresponding // remote images either don't exist or have different size: images.filter { image -> - remoteImageLengths[image.filename]?.let { image.size != it } ?: true + remoteImageLengths[image.filename]?.let { image.size.toLong() != it } ?: true } } else images.toList() ) missingImages.forEach { image -> - UploadImageTask(engine, image).run(triggerStatus) { task -> - if (!task.success) success = false + UploadImageTask(engine, image).run(triggerStatus) { childResult -> processedFiles++ - notifyIfFinished() + if (childResult.hasNetworkError || processedFiles == missingImages.size) onResult(childResult) } } - notifyIfFinished() + + if (missingImages.isEmpty()) onResult(getResultForEmptyList()) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadNoteCombosTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadNoteCombosTask.kt new file mode 100644 index 0000000..df43c21 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadNoteCombosTask.kt @@ -0,0 +1,32 @@ +package us.huseli.retain.syncbackend.tasks + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import us.huseli.retain.Constants +import us.huseli.retain.data.entities.NoteCombo +import us.huseli.retain.syncbackend.Engine +import java.io.File +import java.io.FileWriter + +class UploadNoteCombosTask( + engine: ET, + private val combos: Collection +) : OperationTask(engine) { + private val filename = "noteCombos.json" + private val remotePath = engine.getAbsolutePath(Constants.SYNCBACKEND_JSON_SUBDIR, filename) + private val localFile = File(engine.tempDirUp, filename).apply { deleteOnExit() } + + override fun start(onResult: (OperationTaskResult) -> Unit) { + engine.ioScope.launch { + withContext(Dispatchers.IO) { + try { + FileWriter(localFile).use { it.write(engine.gson.toJson(combos)) } + engine.uploadFile(localFile, remotePath, "application/json", onResult) + } catch (e: Exception) { + onResult(OperationTaskResult(status = TaskResult.Status.OTHER_ERROR, exception = e)) + } + } + } + } +} diff --git a/app/src/main/java/us/huseli/retain/viewmodels/NoteViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/NoteViewModel.kt index 8b83a2c..776d68e 100644 --- a/app/src/main/java/us/huseli/retain/viewmodels/NoteViewModel.kt +++ b/app/src/main/java/us/huseli/retain/viewmodels/NoteViewModel.kt @@ -13,8 +13,8 @@ import kotlinx.coroutines.launch import org.burnoutcrew.reorderable.ItemPosition import us.huseli.retain.LogInterface import us.huseli.retain.Logger -import us.huseli.retain.data.NextCloudRepository import us.huseli.retain.data.NoteRepository +import us.huseli.retain.data.SyncBackendRepository import us.huseli.retain.data.entities.ChecklistItem import us.huseli.retain.data.entities.Image import us.huseli.retain.data.entities.Note @@ -32,7 +32,7 @@ data class NoteCardChecklistData( @HiltViewModel class NoteViewModel @Inject constructor( private val repository: NoteRepository, - private val nextCloudRepository: NextCloudRepository, + private val syncBackendRepository: SyncBackendRepository, override val logger: Logger ) : ViewModel(), LogInterface { private val _selectedNoteIds = MutableStateFlow>(emptySet()) @@ -40,7 +40,8 @@ class NoteViewModel @Inject constructor( private val _notes = MutableStateFlow>(emptyList()) private val _showArchive = MutableStateFlow(false) - val isNextCloudRefreshing = nextCloudRepository.hasActiveTasks + val syncBackend = syncBackendRepository.syncBackend + val isSyncBackendRefreshing = syncBackendRepository.hasActiveTasks val showArchive = _showArchive.asStateFlow() val trashedNoteCount = _trashedNotes.map { it.size } val isSelectEnabled = _selectedNoteIds.map { it.isNotEmpty() } @@ -106,7 +107,7 @@ class NoteViewModel @Inject constructor( _trashedNotes.value = emptySet() viewModelScope.launch { repository.deleteTrashedNotes() - nextCloudRepository.uploadNotes() + syncBackendRepository.uploadNotes() } } @@ -124,7 +125,7 @@ class NoteViewModel @Inject constructor( if (deletedChecklistItemIds.isNotEmpty()) repository.deleteChecklistItems(deletedChecklistItemIds) if (deletedImageIds.isNotEmpty()) { val deletedImages = repository.listImages(deletedImageIds) - nextCloudRepository.removeImages(deletedImages) + syncBackendRepository.removeImages(deletedImages) repository.deleteImages(deletedImages) } } @@ -145,7 +146,7 @@ class NoteViewModel @Inject constructor( _notes.value = _notes.value.toMutableList().apply { add(to.index, removeAt(from.index)) } } - fun syncNextCloud() = nextCloudRepository.sync() + fun syncBackend() = viewModelScope.launch { syncBackendRepository.sync() } fun toggleNoteSelected(noteId: UUID) { if (_selectedNoteIds.value.contains(noteId)) _selectedNoteIds.value -= noteId @@ -182,6 +183,6 @@ class NoteViewModel @Inject constructor( } fun uploadNotes() = viewModelScope.launch { - nextCloudRepository.uploadNotes() + syncBackendRepository.uploadNotes() } } diff --git a/app/src/main/java/us/huseli/retain/viewmodels/SettingsViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/SettingsViewModel.kt index 84ecaaf..7b38e4b 100644 --- a/app/src/main/java/us/huseli/retain/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/us/huseli/retain/viewmodels/SettingsViewModel.kt @@ -13,12 +13,14 @@ import androidx.lifecycle.viewModelScope import androidx.preference.PreferenceManager import com.google.gson.Gson import com.google.gson.GsonBuilder +import com.jcraft.jsch.JSch import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.internal.toImmutableList @@ -27,24 +29,34 @@ import us.huseli.retain.Constants.DEFAULT_MIN_COLUMN_WIDTH import us.huseli.retain.Constants.NEXTCLOUD_BASE_DIR import us.huseli.retain.Constants.PREF_MIN_COLUMN_WIDTH import us.huseli.retain.Constants.PREF_NEXTCLOUD_BASE_DIR -import us.huseli.retain.Constants.PREF_NEXTCLOUD_ENABLED import us.huseli.retain.Constants.PREF_NEXTCLOUD_PASSWORD import us.huseli.retain.Constants.PREF_NEXTCLOUD_URI import us.huseli.retain.Constants.PREF_NEXTCLOUD_USERNAME +import us.huseli.retain.Constants.PREF_SFTP_BASE_DIR +import us.huseli.retain.Constants.PREF_SFTP_HOSTNAME +import us.huseli.retain.Constants.PREF_SFTP_PASSWORD +import us.huseli.retain.Constants.PREF_SFTP_PORT +import us.huseli.retain.Constants.PREF_SFTP_USERNAME +import us.huseli.retain.Constants.PREF_SYNC_BACKEND +import us.huseli.retain.Constants.SFTP_BASE_DIR import us.huseli.retain.Enums.NoteType +import us.huseli.retain.Enums.SyncBackend import us.huseli.retain.LogInterface import us.huseli.retain.Logger import us.huseli.retain.copyFileToLocal -import us.huseli.retain.data.NextCloudRepository import us.huseli.retain.data.NoteRepository +import us.huseli.retain.data.SyncBackendRepository import us.huseli.retain.data.entities.ChecklistItem import us.huseli.retain.data.entities.Image import us.huseli.retain.data.entities.Note import us.huseli.retain.data.entities.NoteCombo import us.huseli.retain.extractFileFromZip import us.huseli.retain.isImageFile -import us.huseli.retain.nextcloud.tasks.TestNextCloudTaskResult import us.huseli.retain.readTextFileFromZip +import us.huseli.retain.syncbackend.NextCloudEngine +import us.huseli.retain.syncbackend.SFTPEngine +import us.huseli.retain.syncbackend.tasks.TaskResult +import us.huseli.retain.syncbackend.tasks.TestTaskResult import us.huseli.retain.uriToImage import java.io.File import java.time.Instant @@ -90,161 +102,76 @@ data class GoogleNoteEntry( class SettingsViewModel @Inject constructor( @ApplicationContext context: Context, private val repository: NoteRepository, - private val nextCloudRepository: NextCloudRepository, + private val syncBackendRepository: SyncBackendRepository, + private val sftpEngine: SFTPEngine, + private val nextCloudEngine: NextCloudEngine, override val logger: Logger, ) : ViewModel(), SharedPreferences.OnSharedPreferenceChangeListener, LogInterface { private val preferences = PreferenceManager.getDefaultSharedPreferences(context) - private val _importActionCount = MutableStateFlow(null) - private val _importCurrentAction = MutableStateFlow("") - private val _importCurrentActionIndex = MutableStateFlow(0) - private val _keepImportIsActive = MutableStateFlow(false) - private val _quickNoteImportIsActive = MutableStateFlow(false) - private var _importJob: Job? = null - private val _sectionsShown = mutableMapOf>() - private var _nextCloudDataChanged = false + private var importJob: Job? = null + private val sectionsShown = mutableMapOf>() + private var syncBackendDataChanged = false + private val jsch = JSch() + private val knownHostsFile = File(context.filesDir, "known_hosts") + private var originalSyncBackend = preferences.getString(PREF_SYNC_BACKEND, null)?.let { SyncBackend.valueOf(it) } - val nextCloudUri = MutableStateFlow(preferences.getString(PREF_NEXTCLOUD_URI, "") ?: "") - val nextCloudUsername = MutableStateFlow(preferences.getString(PREF_NEXTCLOUD_USERNAME, "") ?: "") - val nextCloudPassword = MutableStateFlow(preferences.getString(PREF_NEXTCLOUD_PASSWORD, "") ?: "") - val nextCloudBaseDir = MutableStateFlow(preferences.getString(PREF_NEXTCLOUD_BASE_DIR, NEXTCLOUD_BASE_DIR) ?: "") val minColumnWidth = MutableStateFlow(preferences.getInt(PREF_MIN_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH)) - val isNextCloudTesting = MutableStateFlow(false) + val syncBackend = MutableStateFlow(originalSyncBackend) + + val importActionCount = MutableStateFlow(null) + val importCurrentAction = MutableStateFlow("") + val importCurrentActionIndex = MutableStateFlow(0) + val keepImportIsActive = MutableStateFlow(false) + val quickNoteImportIsActive = MutableStateFlow(false) + val syncBackendNeedsTesting = syncBackendRepository.needsTesting.asStateFlow() + val isSyncBackendEnabled = syncBackend.map { it != null && it != SyncBackend.NONE } + + val isNextCloudAuthError = MutableStateFlow(false) + val isNextCloudTesting = nextCloudEngine.isTesting + val isNextCloudUrlError = MutableStateFlow(false) val isNextCloudWorking = MutableStateFlow(null) - val isNextCloudUrlFail = MutableStateFlow(false) - val isNextCloudCredentialsFail = MutableStateFlow(false) - val isNextCloudEnabled = MutableStateFlow(preferences.getBoolean(PREF_NEXTCLOUD_ENABLED, false)) - val nextCloudNeedsTesting = repository.nextCloudNeedsTesting.asStateFlow() - val importActionCount = _importActionCount.asStateFlow() - val importCurrentAction = _importCurrentAction.asStateFlow() - val importCurrentActionIndex = _importCurrentActionIndex.asStateFlow() - val keepImportIsActive = _keepImportIsActive.asStateFlow() - val quickNoteImportIsActive = _quickNoteImportIsActive.asStateFlow() + val nextCloudBaseDir = MutableStateFlow( + preferences.getString(PREF_NEXTCLOUD_BASE_DIR, NEXTCLOUD_BASE_DIR) ?: NEXTCLOUD_BASE_DIR + ) + val nextCloudPassword = MutableStateFlow(preferences.getString(PREF_NEXTCLOUD_PASSWORD, "") ?: "") + val nextCloudUri = MutableStateFlow(preferences.getString(PREF_NEXTCLOUD_URI, "") ?: "") + val nextCloudUsername = MutableStateFlow(preferences.getString(PREF_NEXTCLOUD_USERNAME, "") ?: "") + + val isSFTPTesting = sftpEngine.isTesting + val isSFTPWorking = MutableStateFlow(null) + val sftpHostname = MutableStateFlow(preferences.getString(PREF_SFTP_HOSTNAME, "") ?: "") + val sftpPassword = MutableStateFlow(preferences.getString(PREF_SFTP_PASSWORD, "") ?: "") + val sftpPort = MutableStateFlow(preferences.getInt(PREF_SFTP_PORT, 22)) + val sftpUsername = MutableStateFlow(preferences.getString(PREF_SFTP_USERNAME, "") ?: "") + val sftpBaseDir = MutableStateFlow(preferences.getString(PREF_SFTP_BASE_DIR, SFTP_BASE_DIR) ?: SFTP_BASE_DIR) + val sftpPromptYesNo = sftpEngine.promptYesNo init { preferences.registerOnSharedPreferenceChangeListener(this) + jsch.setKnownHosts(knownHostsFile.absolutePath) } + fun approveSFTPKey() = sftpEngine.approveKey() + fun cancelImport() { - _importJob?.cancel() - _quickNoteImportIsActive.value = false - _keepImportIsActive.value = false + importJob?.cancel() + quickNoteImportIsActive.value = false + keepImportIsActive.value = false } - @Suppress("SameReturnValue") - private suspend fun extractQuickNoteSqd( - file: File, - context: Context, - notePosition: Int, - baseDir: String = "" - ): NoteCombo? { - val zipFile = withContext(Dispatchers.IO) { ZipFile(file) } - var quickNoteEntry: QuickNoteEntry? = null - var text = "" - val gson: Gson = GsonBuilder().create() - - zipFile.getEntry("${baseDir}metadata.json")?.let { zipEntry -> - updateCurrentAction("Parsing ${zipEntry.name}") - val json = readTextFileFromZip(zipFile, zipEntry) - gson.fromJson(json, QuickNoteEntry::class.java)?.let { - quickNoteEntry = it - log(quickNoteEntry.toString()) - } - } - - // red none green yellow blue orange teal purple violet pink - @Suppress("destructure") - quickNoteEntry?.let { entry -> - zipFile.getEntry("${baseDir}index.html")?.let { zipEntry -> - updateCurrentAction("Parsing ${zipEntry.name}") - val html = readTextFileFromZip(zipFile, zipEntry) - val doc = Jsoup.parseBodyFragment(html) - text = doc.body().wholeText().trim().replace("\n\n", "\n") - } - - val checklistItems = mutableListOf() - val images = mutableListOf() - var imagePosition = 0 - var checklistItemPosition = 0 - val title = - entry.title - ?: (if (baseDir.isNotBlank()) Regex("(?:.*/)?([^/.]+?)(?:\\.sqd)?/?$").find(baseDir)?.groupValues?.last() else null) - ?: file.nameWithoutExtension - - updateCurrentAction("Converting note: $title") - - val note = Note( - type = if (entry.todolists?.isNotEmpty() == true) NoteType.CHECKLIST else NoteType.TEXT, - title = title, - text = text, - created = if (entry.creation_date != null) Instant.ofEpochMilli(entry.creation_date) else Instant.now(), - updated = if (entry.last_modification_date != null) Instant.ofEpochMilli(entry.last_modification_date) else Instant.now(), - position = notePosition, - color = when (entry.color) { - "red" -> "RED" - "green" -> "GREEN" - "yellow" -> "YELLOW" - "blue" -> "BLUE" - "orange" -> "ORANGE" - "teal" -> "TEAL" - "purple" -> "PURPLE" - "violet" -> "PURPLE" - "pink" -> "PINK" - else -> "DEFAULT" - }, - ) - - entry.todolists?.flatMap { it.todo ?: emptyList() }?.forEach { - checklistItems.add(ChecklistItem(text = it, noteId = note.id, position = checklistItemPosition++)) - } - entry.todolists?.flatMap { it.done ?: emptyList() }?.forEach { - checklistItems.add( - ChecklistItem(text = it, checked = true, noteId = note.id, position = checklistItemPosition++) - ) - } - - zipFile.entries().iterator().forEach { zipEntry -> - if (zipEntry != null && zipEntry.name.startsWith("${baseDir}data/") && isImageFile(zipEntry.name)) { - val basename = zipEntry.name.substringAfterLast('/') - if (!basename.startsWith("preview_")) { - val tempDir = File( - context.cacheDir, - if (baseDir.isNotEmpty()) "quicknote/${file.name}-images/$baseDir" - else "quicknote/${file.name}-images" - ).apply { mkdirs() } - val imageFile = File(tempDir, basename).apply { deleteOnExit() } - updateCurrentAction("Extracting ${zipEntry.name}") - extractFileFromZip(zipFile, zipEntry, imageFile) - if (imageFile.exists()) { - updateCurrentAction("Copying ${imageFile.name}") - uriToImage(context, imageFile.toUri(), note.id)?.let { image -> - images.add(image.copy(position = imagePosition++)) - } - } - } - } - } - - return NoteCombo( - note = note, - checklistItems = checklistItems.toImmutableList(), - images = images.toImmutableList() - ) - } - - return null - } + fun denySFTPKey() = sftpEngine.denyKey() fun getSectionShown(key: String, default: Boolean): State { - return _sectionsShown[key] ?: mutableStateOf(default).also { _sectionsShown[key] = it } + return sectionsShown[key] ?: mutableStateOf(default).also { sectionsShown[key] = it } } @Suppress("destructure") fun keepImport(zipUri: Uri, context: Context) { - _keepImportIsActive.value = true - _importCurrentActionIndex.value = 0 - _importCurrentAction.value = "" + keepImportIsActive.value = true + importCurrentActionIndex.value = 0 + importCurrentAction.value = "" - _importJob = viewModelScope.launch { + importJob = viewModelScope.launch { try { val tempDir = File(context.cacheDir, "keep").apply { mkdirs() } val keepFile = File( @@ -271,7 +198,7 @@ class SettingsViewModel @Inject constructor( } } - _importActionCount.value = ((noteCount + imageCount) * 2) + if (noteCount > 0) 1 else 0 + importActionCount.value = ((noteCount + imageCount) * 2) + if (noteCount > 0) 1 else 0 zipFile.entries().iterator().forEach { zipEntry -> zipEntry?.let { @@ -343,15 +270,15 @@ class SettingsViewModel @Inject constructor( } catch (e: Exception) { log("Error: $e", level = Log.ERROR, showInSnackbar = true) } finally { - _keepImportIsActive.value = false + keepImportIsActive.value = false } } } fun quickNoteImport(zipUri: Uri, context: Context) { - _quickNoteImportIsActive.value = true - _importCurrentActionIndex.value = 0 - _importCurrentAction.value = "" + quickNoteImportIsActive.value = true + importCurrentActionIndex.value = 0 + importCurrentAction.value = "" viewModelScope.launch { try { @@ -401,17 +328,11 @@ class SettingsViewModel @Inject constructor( } catch (e: Exception) { log("Error: $e", level = Log.ERROR, showInSnackbar = true) } finally { - _quickNoteImportIsActive.value = false + quickNoteImportIsActive.value = false } } } - private fun resetNextCloudStatus() { - isNextCloudWorking.value = null - isNextCloudUrlFail.value = false - isNextCloudCredentialsFail.value = false - } - fun save() { preferences.edit() .putString(PREF_NEXTCLOUD_URI, nextCloudUri.value) @@ -419,40 +340,69 @@ class SettingsViewModel @Inject constructor( .putString(PREF_NEXTCLOUD_PASSWORD, nextCloudPassword.value) .putString(PREF_NEXTCLOUD_BASE_DIR, nextCloudBaseDir.value) .putInt(PREF_MIN_COLUMN_WIDTH, minColumnWidth.value) - .putBoolean(PREF_NEXTCLOUD_ENABLED, isNextCloudEnabled.value) + .putString(PREF_SYNC_BACKEND, syncBackend.value?.name) + .putString(PREF_SFTP_HOSTNAME, sftpHostname.value) + .putInt(PREF_SFTP_PORT, sftpPort.value) + .putString(PREF_SFTP_USERNAME, sftpUsername.value) + .putString(PREF_SFTP_PASSWORD, sftpPassword.value) + .putString(PREF_SFTP_BASE_DIR, sftpBaseDir.value) .apply() - if (_nextCloudDataChanged) { - repository.nextCloudNeedsTesting.value = true - _nextCloudDataChanged = false + if (syncBackendDataChanged) { + syncBackendRepository.needsTesting.value = true + syncBackendDataChanged = false + } + if (!listOf(SyncBackend.NONE, originalSyncBackend, null).contains(syncBackend.value)) { + syncBackendRepository.needsTesting.value = true + originalSyncBackend = syncBackend.value + viewModelScope.launch { + syncBackendRepository.sync() + } + } + } + + fun testSyncBackend(onResult: (TestTaskResult) -> Unit) { + when (syncBackend.value) { + SyncBackend.NEXTCLOUD -> testNextCloud(onResult) + SyncBackend.SFTP -> testSFTP(onResult) + else -> {} } } - fun testNextCloud(onResult: (TestNextCloudTaskResult) -> Unit) = viewModelScope.launch { - if (isNextCloudEnabled.value) { - repository.nextCloudNeedsTesting.value = false - isNextCloudTesting.value = true - nextCloudRepository.test( + fun testNextCloud(onResult: (TestTaskResult) -> Unit) = viewModelScope.launch { + if (syncBackend.value == SyncBackend.NEXTCLOUD) { + syncBackendRepository.needsTesting.value = false + nextCloudEngine.test( Uri.parse(nextCloudUri.value), nextCloudUsername.value, nextCloudPassword.value, nextCloudBaseDir.value, ) { result -> - isNextCloudTesting.value = false isNextCloudWorking.value = result.success - isNextCloudUrlFail.value = result.isUrlFail - isNextCloudCredentialsFail.value = result.isCredentialsFail + isNextCloudUrlError.value = + result.status == TaskResult.Status.UNKNOWN_HOST || result.status == TaskResult.Status.CONNECT_ERROR + isNextCloudAuthError.value = result.status == TaskResult.Status.AUTH_ERROR onResult(result) } } } - fun toggleSectionShown(key: String) { - _sectionsShown.getOrPut(key) { mutableStateOf(true) }.apply { value = !value } + fun testSFTP(onResult: (TestTaskResult) -> Unit) = viewModelScope.launch(Dispatchers.IO) { + if (syncBackend.value == SyncBackend.SFTP) { + syncBackendRepository.needsTesting.value = false + sftpEngine.test( + sftpHostname.value, + sftpUsername.value, + sftpPassword.value, + sftpBaseDir.value, + ) { result -> + isSFTPWorking.value = result.success + onResult(result) + } + } } - private fun updateCurrentAction(action: String) { - _importCurrentActionIndex.value++ - _importCurrentAction.value = action + fun toggleSectionShown(key: String) { + sectionsShown.getOrPut(key) { mutableStateOf(true) }.apply { value = !value } } fun updateField(field: String, value: Any) { @@ -460,42 +410,204 @@ class SettingsViewModel @Inject constructor( PREF_NEXTCLOUD_URI -> { if (value != nextCloudUri.value) { nextCloudUri.value = value as String - _nextCloudDataChanged = true + syncBackendDataChanged = true resetNextCloudStatus() } } - PREF_NEXTCLOUD_USERNAME -> { if (value != nextCloudUsername.value) { nextCloudUsername.value = value as String - _nextCloudDataChanged = true + syncBackendDataChanged = true resetNextCloudStatus() } } - PREF_NEXTCLOUD_PASSWORD -> { if (value != nextCloudPassword.value) { nextCloudPassword.value = value as String - _nextCloudDataChanged = true + syncBackendDataChanged = true resetNextCloudStatus() } } - - PREF_NEXTCLOUD_BASE_DIR -> nextCloudBaseDir.value = value as String PREF_MIN_COLUMN_WIDTH -> minColumnWidth.value = value as Int - PREF_NEXTCLOUD_ENABLED -> isNextCloudEnabled.value = value as Boolean + PREF_NEXTCLOUD_BASE_DIR -> nextCloudBaseDir.value = value as String + PREF_SFTP_BASE_DIR -> { + if (value != sftpBaseDir.value) { + sftpBaseDir.value = value as String + syncBackendDataChanged = true + resetSFTPStatus() + } + } + PREF_SFTP_HOSTNAME -> { + if (value != sftpHostname.value) { + sftpHostname.value = value as String + syncBackendDataChanged = true + resetSFTPStatus() + } + } + PREF_SFTP_PASSWORD -> { + if (value != sftpPassword.value) { + sftpPassword.value = value as String + syncBackendDataChanged = true + resetSFTPStatus() + } + } + PREF_SFTP_PORT -> { + if (value != sftpPort.value) { + sftpPort.value = value as Int + syncBackendDataChanged = true + resetSFTPStatus() + } + } + PREF_SFTP_USERNAME -> { + if (value != sftpUsername.value) { + sftpUsername.value = value as String + syncBackendDataChanged = true + resetSFTPStatus() + } + } + PREF_SYNC_BACKEND -> { + if (value != syncBackend.value) { + syncBackend.value = value as SyncBackend + preferences.edit().putString(PREF_SYNC_BACKEND, syncBackend.value?.name).apply() + } + } } } + @Suppress("SameReturnValue") + private suspend fun extractQuickNoteSqd( + file: File, + context: Context, + notePosition: Int, + baseDir: String = "" + ): NoteCombo? { + val zipFile = withContext(Dispatchers.IO) { ZipFile(file) } + var quickNoteEntry: QuickNoteEntry? = null + var text = "" + val gson: Gson = GsonBuilder().create() + + zipFile.getEntry("${baseDir}metadata.json")?.let { zipEntry -> + updateCurrentAction("Parsing ${zipEntry.name}") + val json = readTextFileFromZip(zipFile, zipEntry) + gson.fromJson(json, QuickNoteEntry::class.java)?.let { + quickNoteEntry = it + log(quickNoteEntry.toString()) + } + } + + // red none green yellow blue orange teal purple violet pink + @Suppress("destructure") + quickNoteEntry?.let { entry -> + zipFile.getEntry("${baseDir}index.html")?.let { zipEntry -> + updateCurrentAction("Parsing ${zipEntry.name}") + val html = readTextFileFromZip(zipFile, zipEntry) + val doc = Jsoup.parseBodyFragment(html) + text = doc.body().wholeText().trim().replace("\n\n", "\n") + } + + val checklistItems = mutableListOf() + val images = mutableListOf() + var imagePosition = 0 + var checklistItemPosition = 0 + val title = + entry.title + ?: (if (baseDir.isNotBlank()) Regex("(?:.*/)?([^/.]+?)(?:\\.sqd)?/?$").find(baseDir)?.groupValues?.last() else null) + ?: file.nameWithoutExtension + + updateCurrentAction("Converting note: $title") + + val note = Note( + type = if (entry.todolists?.isNotEmpty() == true) NoteType.CHECKLIST else NoteType.TEXT, + title = title, + text = text, + created = if (entry.creation_date != null) Instant.ofEpochMilli(entry.creation_date) else Instant.now(), + updated = if (entry.last_modification_date != null) Instant.ofEpochMilli(entry.last_modification_date) else Instant.now(), + position = notePosition, + color = when (entry.color) { + "red" -> "RED" + "green" -> "GREEN" + "yellow" -> "YELLOW" + "blue" -> "BLUE" + "orange" -> "ORANGE" + "teal" -> "TEAL" + "purple" -> "PURPLE" + "violet" -> "PURPLE" + "pink" -> "PINK" + else -> "DEFAULT" + }, + ) + + entry.todolists?.flatMap { it.todo ?: emptyList() }?.forEach { + checklistItems.add(ChecklistItem(text = it, noteId = note.id, position = checklistItemPosition++)) + } + entry.todolists?.flatMap { it.done ?: emptyList() }?.forEach { + checklistItems.add( + ChecklistItem(text = it, checked = true, noteId = note.id, position = checklistItemPosition++) + ) + } + + zipFile.entries().iterator().forEach { zipEntry -> + if (zipEntry != null && zipEntry.name.startsWith("${baseDir}data/") && isImageFile(zipEntry.name)) { + val basename = zipEntry.name.substringAfterLast('/') + if (!basename.startsWith("preview_")) { + val tempDir = File( + context.cacheDir, + if (baseDir.isNotEmpty()) "quicknote/${file.name}-images/$baseDir" + else "quicknote/${file.name}-images" + ).apply { mkdirs() } + val imageFile = File(tempDir, basename).apply { deleteOnExit() } + updateCurrentAction("Extracting ${zipEntry.name}") + extractFileFromZip(zipFile, zipEntry, imageFile) + if (imageFile.exists()) { + updateCurrentAction("Copying ${imageFile.name}") + uriToImage(context, imageFile.toUri(), note.id)?.let { image -> + images.add(image.copy(position = imagePosition++)) + } + } + } + } + } + + return NoteCombo( + note = note, + checklistItems = checklistItems.toImmutableList(), + images = images.toImmutableList() + ) + } + + return null + } + + private fun resetNextCloudStatus() { + isNextCloudWorking.value = null + isNextCloudUrlError.value = false + isNextCloudAuthError.value = false + } + + private fun resetSFTPStatus() { + isSFTPWorking.value = null + } + + private fun updateCurrentAction(action: String) { + importCurrentActionIndex.value++ + importCurrentAction.value = action + } + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { when (key) { - PREF_NEXTCLOUD_URI -> nextCloudUri.value = preferences.getString(key, "") ?: "" - PREF_NEXTCLOUD_USERNAME -> nextCloudUsername.value = preferences.getString(key, "") ?: "" - PREF_NEXTCLOUD_PASSWORD -> nextCloudPassword.value = preferences.getString(key, "") ?: "" PREF_MIN_COLUMN_WIDTH -> minColumnWidth.value = preferences.getInt(key, DEFAULT_MIN_COLUMN_WIDTH) - PREF_NEXTCLOUD_ENABLED -> isNextCloudEnabled.value = preferences.getBoolean(PREF_NEXTCLOUD_ENABLED, false) PREF_NEXTCLOUD_BASE_DIR -> nextCloudBaseDir.value = preferences.getString(key, NEXTCLOUD_BASE_DIR) ?: NEXTCLOUD_BASE_DIR + PREF_NEXTCLOUD_PASSWORD -> nextCloudPassword.value = preferences.getString(key, "") ?: "" + PREF_NEXTCLOUD_URI -> nextCloudUri.value = preferences.getString(key, "") ?: "" + PREF_NEXTCLOUD_USERNAME -> nextCloudUsername.value = preferences.getString(key, "") ?: "" + PREF_SFTP_BASE_DIR -> sftpBaseDir.value = preferences.getString(key, SFTP_BASE_DIR) ?: SFTP_BASE_DIR + PREF_SFTP_HOSTNAME -> sftpHostname.value = preferences.getString(key, "") ?: "" + PREF_SFTP_PASSWORD -> sftpPassword.value = preferences.getString(key, "") ?: "" + PREF_SFTP_PORT -> sftpPort.value = preferences.getInt(key, 22) + PREF_SFTP_USERNAME -> sftpUsername.value = preferences.getString(key, "") ?: "" + PREF_SYNC_BACKEND -> syncBackend.value = + preferences.getString(key, null)?.let { SyncBackend.valueOf(it) } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54a7ff1..45ca637 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,7 +12,6 @@ Settings Nextcloud username Nextcloud password - Nextcloud sync General Minimum column width on home screen Close @@ -28,8 +27,8 @@ Unknown error Successfully connected to Nextcloud. The server reported an authorization error. Username and/or password is probably incorrect. - Server reported “file not found”. There is probably no Nextcloud instance at this address. - Failed to connect to Nextcloud + No host with this name was found. + Failed to connect to host. Hide password Show password Nextcloud base path @@ -53,8 +52,20 @@ Delete selected images Quicknote import Import from a zip file of Quicknote notes. Only tested with Carnet exports. - Enable Nextcloud sync - Syncing with Nextcloud + Syncing with %1$s + Backend sync + Sync with Nextcloud + Sync with SFTP + SFTP hostname + SFTP port + SFTP username + SFTP password + No + Yes + Do not sync + SFTP base directory + Relative to the user\'s home directory. + backend + 1 item + %1$d items