diff --git a/.gitignore b/.gitignore index f9dba8e..b56a0ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,15 @@ *.iml +.DS_Store +.cxx +.externalNativeBuild .gradle -/local.properties /.idea -.DS_Store /build /captures -.externalNativeBuild -.cxx +keystore.properties local.properties -/keystore.properties /notes.txt +secrets.properties # Built application files *.apk diff --git a/README.md b/README.md index 798b016..b52fd75 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Retain -It's a note/checklist app. Heavily inspired by Google Keep, except it's not evil (i.e. open source, doesn't track you). You can sync your notes to a Nextcloud account if you so desire. +It's a note/checklist app. Heavily inspired by Google Keep, except it's not evil (i.e. open source, doesn't track you). You can sync your notes to a Nextcloud, SFTP, or Dropbox account if you so desire. Not yet available on any appstores etc. But it will probably come. Don't get your knickers in a twist! diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 44d0f24..0f6dad5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,7 +4,10 @@ import java.io.FileInputStream import java.util.Properties val keystoreProperties = Properties() +val secretsProperties = Properties() + keystoreProperties.load(FileInputStream(rootProject.file("keystore.properties"))) +secretsProperties.load(FileInputStream(rootProject.file("secrets.properties"))) plugins { id("com.android.application") @@ -16,7 +19,7 @@ plugins { } kotlin { - jvmToolchain(8) + jvmToolchain(17) } android { @@ -32,37 +35,50 @@ android { compileSdk = 33 defaultConfig { + // val dropboxAppKey = secretsProperties["dropboxAppKey"] as String + applicationId = "us.huseli.retain" minSdk = 26 targetSdk = 33 versionCode = 1 - versionName = "1.0" + versionName = "1.0.0-beta.1" vectorDrawables.useSupportLibrary = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // buildConfigField("String", "dropboxAppKey", "\"${dropboxAppKey}\"") + // manifestPlaceholders["dropboxAppKey"] = dropboxAppKey + setProperty("archivesBaseName", "retain_$versionName") } buildTypes { debug { + val dropboxAppKey = secretsProperties["dropboxAppKeyDebug"] as String + isDebuggable = true isRenderscriptDebuggable = true applicationIdSuffix = ".debug" + buildConfigField("String", "dropboxAppKey", "\"${dropboxAppKey}\"") + manifestPlaceholders["dropboxAppKey"] = dropboxAppKey } release { + val dropboxAppKey = secretsProperties["dropboxAppKey"] as String + proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) signingConfig = signingConfigs.getByName("release") + buildConfigField("String", "dropboxAppKey", "\"${dropboxAppKey}\"") + manifestPlaceholders["dropboxAppKey"] = dropboxAppKey } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "17" } buildFeatures { @@ -85,12 +101,11 @@ dependencies { implementation("com.google.devtools.ksp:symbol-processing-api:1.8.10-1.0.9") implementation("androidx.core:core-ktx:1.10.1") implementation("androidx.core:core-splashscreen:1.0.1") - implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6") - implementation("androidx.recyclerview:recyclerview:1.3.0") + implementation("androidx.recyclerview:recyclerview:1.3.1") implementation("androidx.preference:preference-ktx:1.2.0") implementation("androidx.activity:activity-compose:1.7.2") - implementation("androidx.navigation:navigation-compose:2.5.3") - // For PickVisualMedia contract + implementation("androidx.navigation:navigation-compose:2.6.0") + // For PickVisualMedia contract: implementation("androidx.activity:activity-ktx:1.7.2") // Lifecycle: @@ -106,23 +121,22 @@ dependencies { } // Compose: - implementation(platform("androidx.compose:compose-bom:2023.05.01")) implementation("androidx.compose.ui:ui:1.4.3") implementation("androidx.compose.ui:ui-graphics:1.4.3") implementation("androidx.compose.ui:ui-tooling-preview:1.4.3") - implementation("androidx.compose.material3:material3:1.1.0") - androidTestImplementation(platform("androidx.compose:compose-bom:2023.05.01")) androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.4.3") debugImplementation("androidx.compose.ui:ui-tooling:1.4.3") debugImplementation("androidx.compose.ui:ui-test-manifest:1.4.3") + + // Material: implementation("androidx.compose.material:material:1.4.3") - implementation("androidx.compose.material3:material3:1.1.0") + implementation("androidx.compose.material3:material3:1.1.1") implementation("androidx.compose.material:material-icons-extended:1.4.3") // Room: - implementation("androidx.room:room-runtime:2.5.1") - ksp("androidx.room:room-compiler:2.5.1") - implementation("androidx.room:room-ktx:2.5.1") + implementation("androidx.room:room-runtime:2.5.2") + ksp("androidx.room:room-compiler:2.5.2") + implementation("androidx.room:room-ktx:2.5.2") // Hilt: implementation("com.google.dagger:hilt-android:2.46.1") @@ -145,7 +159,13 @@ dependencies { // SFTP: implementation(group = "com.github.mwiede", name = "jsch", version = "0.2.9") + // Dropbox: + implementation("com.dropbox.core:dropbox-core-sdk:5.4.5") + + // Theme: + implementation("com.github.Eboreg:RetainTheme:1.1.3") + // testImplementation("junit:junit:4.13.2") // androidTestImplementation("androidx.test.ext:junit:1.1.5") // androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eb888b7..c8c004a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + + + + + + + + + + + + + + + - \ No newline at end of file + + + + diff --git a/app/src/main/java/us/huseli/retain/Constants.kt b/app/src/main/java/us/huseli/retain/Constants.kt index 06197b2..fa3bbcb 100644 --- a/app/src/main/java/us/huseli/retain/Constants.kt +++ b/app/src/main/java/us/huseli/retain/Constants.kt @@ -3,10 +3,12 @@ package us.huseli.retain object Constants { const val DEFAULT_MAX_IMAGE_DIMEN = 2048 const val DEFAULT_MIN_COLUMN_WIDTH = 180 + const val DEFAULT_NEXTCLOUD_BASE_DIR = "/.retain" + const val DEFAULT_SFTP_BASE_DIR = ".retain" const val IMAGE_SUBDIR = "images" const val NAV_ARG_IMAGE_CAROUSEL_CURRENT_ID = "imageCarouselCurrentId" const val NAV_ARG_NOTE_ID = "noteId" - const val NEXTCLOUD_BASE_DIR = "/.retain" + const val PREF_DROPBOX_CREDENTIAL = "dropboxCredential" const val PREF_MIN_COLUMN_WIDTH = "minColumnWidth" const val PREF_NEXTCLOUD_BASE_DIR = "nextCloudBaseDir" const val PREF_NEXTCLOUD_PASSWORD = "nextCloudPassword" @@ -18,7 +20,6 @@ object Constants { 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 15987cc..63c9dc8 100644 --- a/app/src/main/java/us/huseli/retain/Enums.kt +++ b/app/src/main/java/us/huseli/retain/Enums.kt @@ -8,5 +8,6 @@ object Enums { NONE("None"), NEXTCLOUD("Nextcloud"), SFTP("SFTP"), + DROPBOX("Dropbox"), } } diff --git a/app/src/main/java/us/huseli/retain/Logger.kt b/app/src/main/java/us/huseli/retain/Logger.kt index 69325e8..49249b1 100644 --- a/app/src/main/java/us/huseli/retain/Logger.kt +++ b/app/src/main/java/us/huseli/retain/Logger.kt @@ -32,7 +32,7 @@ data class LogMessage( fun levelToString() = logLevelToString(level) override fun toString() = "$timestamp ${levelToString()} $tag [$thread] $message" override fun equals(other: Any?) = other is LogMessage && other.timestamp == timestamp - override fun hashCode() = timestamp.hashCode() + override fun hashCode(): Int = 31 * timestamp.hashCode() + message.hashCode() } @Singleton @@ -73,19 +73,17 @@ class Logger { interface LogInterface { val logger: Logger - fun log(message: String, level: Int = Log.INFO, showInSnackbar: Boolean = false) { - log(createLogMessage(message, level), showInSnackbar) - } + fun log(message: String, level: Int = Log.INFO, showInSnackbar: Boolean = false) = + logger.log(createLogMessage(message, level), showInSnackbar) - fun log(logMessage: LogMessage, showInSnackbar: Boolean = false) = logger.log(logMessage, showInSnackbar) + fun showError(message: String) = log(message, Log.ERROR, true) - fun logError(prefix: String, logMessage: LogMessage? = null) { - var message = prefix - if (logMessage != null) message += ": ${logMessage.message}" - log(message, Log.ERROR, true) + fun showError(prefix: String, exception: Exception?) { + if (exception != null) showError("$prefix: $exception") + else showError(prefix) } - fun createLogMessage(message: String, level: Int = Log.INFO): LogMessage { + private fun createLogMessage(message: String, level: Int = Log.INFO): LogMessage { return LogMessage( level = level, tag = "${javaClass.simpleName}<${System.identityHashCode(this)}>", diff --git a/app/src/main/java/us/huseli/retain/RetainActivity.kt b/app/src/main/java/us/huseli/retain/RetainActivity.kt index efb06b3..774b62c 100644 --- a/app/src/main/java/us/huseli/retain/RetainActivity.kt +++ b/app/src/main/java/us/huseli/retain/RetainActivity.kt @@ -6,12 +6,15 @@ import androidx.activity.compose.setContent import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import dagger.hilt.android.AndroidEntryPoint import us.huseli.retain.compose.App +import us.huseli.retain.syncbackend.DropboxEngine import javax.inject.Inject @AndroidEntryPoint class RetainActivity : ComponentActivity() { @Inject lateinit var logger: Logger + @Inject + lateinit var dropboxEngine: DropboxEngine override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() @@ -21,4 +24,9 @@ class RetainActivity : ComponentActivity() { App(logger) } } + + override fun onResume() { + super.onResume() + dropboxEngine.onResume() + } } diff --git a/app/src/main/java/us/huseli/retain/compose/App.kt b/app/src/main/java/us/huseli/retain/compose/App.kt index 18ef947..670b4d6 100644 --- a/app/src/main/java/us/huseli/retain/compose/App.kt +++ b/app/src/main/java/us/huseli/retain/compose/App.kt @@ -8,6 +8,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import us.huseli.retaintheme.ui.theme.RetainTheme import us.huseli.retain.ChecklistNoteDestination import us.huseli.retain.DebugDestination import us.huseli.retain.Enums.NoteType @@ -19,7 +20,6 @@ import us.huseli.retain.TextNoteDestination import us.huseli.retain.compose.notescreen.ChecklistNoteScreen import us.huseli.retain.compose.notescreen.TextNoteScreen import us.huseli.retain.compose.settings.SettingsScreen -import us.huseli.retain.ui.theme.RetainTheme import us.huseli.retain.viewmodels.NoteViewModel import us.huseli.retain.viewmodels.SettingsViewModel import java.util.UUID 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 ff7fd8a..1beff6a 100644 --- a/app/src/main/java/us/huseli/retain/compose/HomeScreen.kt +++ b/app/src/main/java/us/huseli/retain/compose/HomeScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -58,7 +57,7 @@ fun HomeScreen( onDebugClick: () -> Unit, ) { val syncBackend by viewModel.syncBackend.collectAsStateWithLifecycle() - val isSyncBackendRefreshing by viewModel.isSyncBackendRefreshing.collectAsStateWithLifecycle(false) + val isSyncBackendSyncing by viewModel.isSyncBackendSyncing.collectAsStateWithLifecycle(false) val isSyncBackendEnabled by settingsViewModel.isSyncBackendEnabled.collectAsStateWithLifecycle(false) val notes by viewModel.notes.collectAsStateWithLifecycle(emptyList()) val images by viewModel.images.collectAsStateWithLifecycle(emptyList()) @@ -81,7 +80,7 @@ fun HomeScreen( if (isSyncBackendEnabled) { val refreshState = rememberPullRefreshState( - refreshing = isSyncBackendRefreshing, + refreshing = isSyncBackendSyncing, onRefresh = { viewModel.syncBackend() }, ) lazyModifier = lazyModifier.pullRefresh(state = refreshState) @@ -115,7 +114,6 @@ fun HomeScreen( RetainScaffold( viewModel = viewModel, - settingsViewModel = settingsViewModel, topBar = { if (isSelectEnabled) SelectionTopAppBar( selectedCount = selectedNoteIds.size, @@ -156,17 +154,14 @@ fun HomeScreen( } Column(modifier = lazyModifier.padding(innerPadding).fillMaxWidth()) { - if (isSyncBackendRefreshing) { + if (isSyncBackendSyncing) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource( - R.string.syncing_with, - syncBackend?.displayName ?: stringResource(R.string.backend) - ), + text = stringResource(R.string.syncing_with, syncBackend.displayName), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(end = 4.dp), ) @@ -177,7 +172,6 @@ fun HomeScreen( if (viewType == HomeScreenViewType.GRID) { LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Adaptive(minSize = minColumnWidth.dp), - contentPadding = PaddingValues(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalItemSpacing = 8.dp, ) { @@ -187,7 +181,6 @@ fun HomeScreen( LazyColumn( modifier = Modifier.reorderable(reorderableState).detectReorder(reorderableState), state = reorderableState.listState, - contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(notes, key = { it.id }) { note -> 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 56684e2..7caaaac 100644 --- a/app/src/main/java/us/huseli/retain/compose/RetainScaffold.kt +++ b/app/src/main/java/us/huseli/retain/compose/RetainScaffold.kt @@ -11,7 +11,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -19,16 +18,13 @@ import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.systemuicontroller.rememberSystemUiController -import kotlinx.coroutines.launch import us.huseli.retain.R import us.huseli.retain.viewmodels.NoteViewModel -import us.huseli.retain.viewmodels.SettingsViewModel @Composable fun RetainScaffold( modifier: Modifier = Modifier, viewModel: NoteViewModel = hiltViewModel(), - settingsViewModel: SettingsViewModel = hiltViewModel(), snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, statusBarColor: Color = MaterialTheme.colorScheme.surface, navigationBarColor: Color = MaterialTheme.colorScheme.background, @@ -36,9 +32,7 @@ fun RetainScaffold( content: @Composable (PaddingValues) -> Unit ) { val context = LocalContext.current - val syncBackendNeedsTesting by settingsViewModel.syncBackendNeedsTesting.collectAsStateWithLifecycle() val snackbarMessage by viewModel.logger.snackbarMessage.collectAsStateWithLifecycle(null) - val scope = rememberCoroutineScope() val trashedNoteCount by viewModel.trashedNoteCount.collectAsStateWithLifecycle(0) val systemUiController = rememberSystemUiController() @@ -79,12 +73,6 @@ fun RetainScaffold( } } - LaunchedEffect(syncBackendNeedsTesting) { - if (syncBackendNeedsTesting) settingsViewModel.testSyncBackend { result -> - if (!result.success) scope.launch { snackbarHostState.showSnackbar(result.getErrorMessage(context)) } - } - } - Scaffold( modifier = modifier, topBar = topBar, diff --git a/app/src/main/java/us/huseli/retain/compose/settings/DropboxSection.kt b/app/src/main/java/us/huseli/retain/compose/settings/DropboxSection.kt new file mode 100644 index 0000000..6cf6e99 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/compose/settings/DropboxSection.kt @@ -0,0 +1,120 @@ +package us.huseli.retain.compose.settings + +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.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import us.huseli.retain.R +import us.huseli.retain.compose.SweepLoadingOverlay +import us.huseli.retain.syncbackend.tasks.TestTaskResult +import us.huseli.retain.viewmodels.DropboxViewModel + +@Composable +fun DropboxSection(modifier: Modifier = Modifier, viewModel: DropboxViewModel = hiltViewModel()) { + val context = LocalContext.current + val accountEmail by viewModel.accountEmail.collectAsStateWithLifecycle() + val isAuthenticated by viewModel.isAuthenticated.collectAsStateWithLifecycle(false) + val isTesting by viewModel.isTesting.collectAsStateWithLifecycle() + val isWorking by viewModel.isWorking.collectAsStateWithLifecycle() + var testResult by remember { mutableStateOf(null) } + + testResult?.let { result -> + if (!result.success) { + ErrorDialog( + title = stringResource(R.string.dropbox_error), + text = result.getErrorMessage(context), + onClose = { testResult = null } + ) + } + } + + BoxWithConstraints(modifier = modifier) { + Column { + if (isAuthenticated) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SuccessIcon(modifier = Modifier.padding(start = 8.dp), circled = true) + Text( + text = stringResource(R.string.connected_to_dropbox_account, accountEmail), + modifier = Modifier.padding(start = 16.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isWorking == true) { + SuccessIcon(modifier = Modifier.padding(start = 8.dp), circled = true) + Text( + text = stringResource(R.string.the_dropbox_connection_is_working), + modifier = Modifier.padding(start = 16.dp), + style = MaterialTheme.typography.bodySmall, + ) + } else if (isWorking == false) { + FailIcon(modifier = Modifier.padding(start = 8.dp), circled = true) + Text( + text = stringResource(R.string.the_dropbox_connection_is_not_working_somehow), + modifier = Modifier.padding(start = 16.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + } + Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { + OutlinedButton( + onClick = { viewModel.revoke() }, + shape = ShapeDefaults.ExtraSmall, + ) { + Text(stringResource(R.string.revoke)) + } + } + } else { + Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { + FailIcon(modifier = Modifier.padding(start = 8.dp), circled = true) + Text( + text = stringResource(R.string.not_connected_to_dropbox), + modifier = Modifier.padding(start = 16.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp)) { + OutlinedButton( + onClick = { viewModel.authenticate() }, + shape = ShapeDefaults.ExtraSmall, + ) { + Text(stringResource(R.string.connect)) + } + } + } + } + if (isTesting) SweepLoadingOverlay(modifier = Modifier.matchParentSize()) + } + Row { + OutlinedButton( + onClick = { viewModel.test { result -> testResult = result } }, + shape = ShapeDefaults.ExtraSmall, + enabled = !isTesting, + ) { + 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/FailIcon.kt b/app/src/main/java/us/huseli/retain/compose/settings/FailIcon.kt new file mode 100644 index 0000000..1437871 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/compose/settings/FailIcon.kt @@ -0,0 +1,23 @@ +package us.huseli.retain.compose.settings + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.sharp.Error +import androidx.compose.material.icons.sharp.PriorityHigh +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import us.huseli.retaintheme.ui.theme.RetainColorDark +import us.huseli.retaintheme.ui.theme.RetainColorLight + +@Composable +fun FailIcon(modifier: Modifier = Modifier, circled: Boolean = false) { + val colors = if (isSystemInDarkTheme()) RetainColorDark else RetainColorLight + + Icon( + modifier = modifier, + imageVector = if (circled) Icons.Sharp.Error else Icons.Sharp.PriorityHigh, + contentDescription = null, + tint = colors.Red, + ) +} diff --git a/app/src/main/java/us/huseli/retain/compose/settings/GeneralSection.kt b/app/src/main/java/us/huseli/retain/compose/settings/GeneralSection.kt index cdf578d..88baa36 100644 --- a/app/src/main/java/us/huseli/retain/compose/settings/GeneralSection.kt +++ b/app/src/main/java/us/huseli/retain/compose/settings/GeneralSection.kt @@ -1,5 +1,6 @@ package us.huseli.retain.compose.settings +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme @@ -44,16 +45,19 @@ fun GeneralSection( ) Row(verticalAlignment = Alignment.CenterVertically) { Slider( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.weight(0.9f), value = minColumnWidthSliderPos.toFloat(), steps = columnWidthSteps, onValueChange = { minColumnWidthSliderPos = it.roundToInt() }, onValueChangeFinished = { viewModel.updateField(PREF_MIN_COLUMN_WIDTH, minColumnWidthSliderPos) }, valueRange = 50f..maxColumnWidth.toFloat(), ) - Text( - text = minColumnWidthSliderPos.toString(), - ) + Column(modifier = Modifier.fillMaxWidth(0.1f)) { + Text( + text = minColumnWidthSliderPos.toString(), + modifier = Modifier.align(Alignment.End), + ) + } } } } \ No newline at end of file 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 48f4dda..feefbbe 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,14 +1,11 @@ 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.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.Icon @@ -33,6 +30,7 @@ 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.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import us.huseli.retain.Constants.PREF_NEXTCLOUD_BASE_DIR import us.huseli.retain.Constants.PREF_NEXTCLOUD_PASSWORD @@ -42,25 +40,23 @@ import us.huseli.retain.R import us.huseli.retain.cleanUri 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 +import us.huseli.retain.viewmodels.NextCloudViewModel @Composable fun NextCloudSection( modifier: Modifier = Modifier, - viewModel: SettingsViewModel, + viewModel: NextCloudViewModel = hiltViewModel(), snackbarHostState: SnackbarHostState, ) { val context = LocalContext.current - val uri by viewModel.nextCloudUri.collectAsStateWithLifecycle("") - val username by viewModel.nextCloudUsername.collectAsStateWithLifecycle("") - val password by viewModel.nextCloudPassword.collectAsStateWithLifecycle("") - val baseDir by viewModel.nextCloudBaseDir.collectAsStateWithLifecycle("") - val isTesting by viewModel.isNextCloudTesting.collectAsStateWithLifecycle() - val isWorking by viewModel.isNextCloudWorking.collectAsStateWithLifecycle() - val isUrlError by viewModel.isNextCloudUrlError.collectAsStateWithLifecycle() - val isAuthError by viewModel.isNextCloudAuthError.collectAsStateWithLifecycle() + val uri by viewModel.uri.collectAsStateWithLifecycle("") + val username by viewModel.username.collectAsStateWithLifecycle("") + val password by viewModel.password.collectAsStateWithLifecycle("") + val baseDir by viewModel.baseDir.collectAsStateWithLifecycle("") + val isTesting by viewModel.isTesting.collectAsStateWithLifecycle() + val isWorking by viewModel.isWorking.collectAsStateWithLifecycle() + val isUrlError by viewModel.isUrlError.collectAsStateWithLifecycle() + val isAuthError by viewModel.isAuthError.collectAsStateWithLifecycle() val successMessage = stringResource(R.string.successfully_connected_to_nextcloud) var testResult by remember { mutableStateOf(null) } @@ -82,22 +78,6 @@ fun NextCloudSection( } } - val colors = if (isSystemInDarkTheme()) RetainColorDark else RetainColorLight - val workingIcon = @Composable { - Icon( - imageVector = Icons.Sharp.Check, - contentDescription = null, - tint = colors.Green, - ) - } - val failIcon = @Composable { - Icon( - imageVector = Icons.Sharp.Error, - contentDescription = null, - tint = colors.Red, - ) - } - BoxWithConstraints(modifier = modifier) { Column { Row(modifier = Modifier.fillMaxWidth()) { @@ -125,8 +105,8 @@ fun NextCloudSection( ), enabled = !isTesting, trailingIcon = { - if (isWorking == true) workingIcon() - else if (isUrlError) failIcon() + if (isWorking == true) SuccessIcon() + else if (isUrlError) FailIcon() }, ) } @@ -140,8 +120,8 @@ fun NextCloudSection( onValueChange = { viewModel.updateField(PREF_NEXTCLOUD_USERNAME, it) }, keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), trailingIcon = { - if (isWorking == true) workingIcon() - else if (isAuthError) failIcon() + if (isWorking == true) SuccessIcon() + else if (isAuthError) FailIcon() }, ) } @@ -167,8 +147,8 @@ fun NextCloudSection( else stringResource(R.string.show_password), ) } - else if (isWorking == true) workingIcon() - else if (isAuthError) failIcon() + else if (isWorking == true) SuccessIcon() + else if (isAuthError) FailIcon() } ) } @@ -181,7 +161,7 @@ fun NextCloudSection( onValueChange = { viewModel.updateField(PREF_NEXTCLOUD_BASE_DIR, it) }, enabled = !isTesting, keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), - trailingIcon = { if (isWorking == true) workingIcon() }, + trailingIcon = { if (isWorking == true) SuccessIcon() }, ) } } @@ -189,7 +169,7 @@ fun NextCloudSection( } Row { OutlinedButton( - onClick = { viewModel.testNextCloud { result -> testResult = result } }, + onClick = { viewModel.test { result -> testResult = result } }, shape = ShapeDefaults.ExtraSmall, enabled = !isTesting && uriState.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty(), ) { 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 index 2ec1959..3d8af74 100644 --- a/app/src/main/java/us/huseli/retain/compose/settings/SFTPSection.kt +++ b/app/src/main/java/us/huseli/retain/compose/settings/SFTPSection.kt @@ -1,6 +1,5 @@ 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 @@ -8,7 +7,6 @@ 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 @@ -33,6 +31,7 @@ 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.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import us.huseli.retain.Constants.PREF_SFTP_BASE_DIR import us.huseli.retain.Constants.PREF_SFTP_HOSTNAME @@ -42,9 +41,7 @@ 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 +import us.huseli.retain.viewmodels.SFTPViewModel @Composable fun PromptYesNoDialog( @@ -73,33 +70,25 @@ fun PromptYesNoDialog( } @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() +fun SFTPSection(modifier: Modifier = Modifier, viewModel: SFTPViewModel = hiltViewModel()) { + val baseDir by viewModel.baseDir.collectAsStateWithLifecycle() + val hostname by viewModel.hostname.collectAsStateWithLifecycle() + val port by viewModel.port.collectAsStateWithLifecycle() + val username by viewModel.username.collectAsStateWithLifecycle() + val password by viewModel.password.collectAsStateWithLifecycle() + val promptYesNo by viewModel.promptYesNo.collectAsStateWithLifecycle() + val isTesting by viewModel.isTesting.collectAsStateWithLifecycle() + val isWorking by viewModel.isWorking.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() }, + onYes = { viewModel.approveKey() }, + onNo = { viewModel.denyKey() }, ) } @@ -118,7 +107,7 @@ fun SFTPSection(modifier: Modifier = Modifier, viewModel: SettingsViewModel) { ), enabled = !isTesting, trailingIcon = { - if (isWorking == true) workingIcon() + if (isWorking == true) SuccessIcon() }, ) OutlinedTextField( @@ -132,7 +121,7 @@ fun SFTPSection(modifier: Modifier = Modifier, viewModel: SettingsViewModel) { ), enabled = !isTesting, trailingIcon = { - if (isWorking == true) workingIcon() + if (isWorking == true) SuccessIcon() }, ) } @@ -146,7 +135,7 @@ fun SFTPSection(modifier: Modifier = Modifier, viewModel: SettingsViewModel) { keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), enabled = !isTesting, trailingIcon = { - if (isWorking == true) workingIcon() + if (isWorking == true) SuccessIcon() }, ) } @@ -175,7 +164,7 @@ fun SFTPSection(modifier: Modifier = Modifier, viewModel: SettingsViewModel) { else stringResource(R.string.show_password), ) } - else if (isWorking == true) workingIcon() + else if (isWorking == true) SuccessIcon() }, enabled = !isTesting, ) @@ -191,7 +180,7 @@ fun SFTPSection(modifier: Modifier = Modifier, viewModel: SettingsViewModel) { supportingText = { Text(stringResource(R.string.relative_to_users_home_directory)) }, enabled = !isTesting, trailingIcon = { - if (isWorking == true) workingIcon() + if (isWorking == true) SuccessIcon() }, ) } @@ -200,7 +189,7 @@ fun SFTPSection(modifier: Modifier = Modifier, viewModel: SettingsViewModel) { } Row { OutlinedButton( - onClick = { viewModel.testSFTP { result -> testResult = result } }, + onClick = { viewModel.test { result -> testResult = result } }, shape = ShapeDefaults.ExtraSmall, enabled = !isTesting && hostname.isNotEmpty() && username.isNotEmpty() && password.isNotEmpty(), ) { diff --git a/app/src/main/java/us/huseli/retain/compose/settings/SuccessIcon.kt b/app/src/main/java/us/huseli/retain/compose/settings/SuccessIcon.kt new file mode 100644 index 0000000..6b4d7af --- /dev/null +++ b/app/src/main/java/us/huseli/retain/compose/settings/SuccessIcon.kt @@ -0,0 +1,23 @@ +package us.huseli.retain.compose.settings + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.sharp.Check +import androidx.compose.material.icons.sharp.CheckCircle +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import us.huseli.retaintheme.ui.theme.RetainColorDark +import us.huseli.retaintheme.ui.theme.RetainColorLight + +@Composable +fun SuccessIcon(modifier: Modifier = Modifier, circled: Boolean = false) { + val colors = if (isSystemInDarkTheme()) RetainColorDark else RetainColorLight + + Icon( + modifier = modifier, + imageVector = if (circled) Icons.Sharp.CheckCircle else Icons.Sharp.Check, + contentDescription = null, + tint = colors.Green, + ) +} 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 index 97ddb30..0c56ac7 100644 --- a/app/src/main/java/us/huseli/retain/compose/settings/SyncBackendSection.kt +++ b/app/src/main/java/us/huseli/retain/compose/settings/SyncBackendSection.kt @@ -69,12 +69,15 @@ fun SyncBackendSection( selected = syncBackend == SyncBackend.SFTP, onClick = { viewModel.updateField(PREF_SYNC_BACKEND, SyncBackend.SFTP) }, ) + SyncBackendRadioButton( + text = stringResource(R.string.sync_with_dropbox), + selected = syncBackend == SyncBackend.DROPBOX, + onClick = { viewModel.updateField(PREF_SYNC_BACKEND, SyncBackend.DROPBOX) }, + ) - if (syncBackend == SyncBackend.NEXTCLOUD) { - NextCloudSection(viewModel = viewModel, snackbarHostState = snackbarHostState) - } - if (syncBackend == SyncBackend.SFTP) { - SFTPSection(viewModel = viewModel) - } + if (syncBackend == SyncBackend.NEXTCLOUD) + NextCloudSection(snackbarHostState = snackbarHostState) + if (syncBackend == SyncBackend.SFTP) SFTPSection() + if (syncBackend == SyncBackend.DROPBOX) DropboxSection() } } diff --git a/app/src/main/java/us/huseli/retain/data/ChecklistItemDao.kt b/app/src/main/java/us/huseli/retain/data/ChecklistItemDao.kt index 90de677..000033a 100644 --- a/app/src/main/java/us/huseli/retain/data/ChecklistItemDao.kt +++ b/app/src/main/java/us/huseli/retain/data/ChecklistItemDao.kt @@ -7,6 +7,7 @@ import androidx.room.Query import androidx.room.Transaction import kotlinx.coroutines.flow.Flow import us.huseli.retain.data.entities.ChecklistItem +import us.huseli.retain.data.entities.ChecklistItemWithNote import java.util.UUID @Dao @@ -17,8 +18,8 @@ interface ChecklistItemDao { @Query("DELETE FROM checklistitem WHERE checklistItemNoteId=:noteId AND checklistItemId NOT IN (:except)") suspend fun deleteByNoteId(noteId: UUID, except: Collection = emptyList()) - @Query("SELECT * FROM checklistitem ORDER BY checklistItemNoteId, checklistItemChecked, checklistItemPosition") - fun flowList(): Flow> + @Query("SELECT * FROM checklistitem INNER JOIN note ON checklistItemNoteId = noteId ORDER BY checklistItemNoteId, checklistItemChecked, checklistItemPosition") + fun flowListWithNote(): Flow> @Query("SELECT * FROM checklistitem WHERE checklistItemNoteId = :noteId ORDER BY checklistItemPosition") suspend fun listByNoteId(noteId: UUID): List diff --git a/app/src/main/java/us/huseli/retain/data/NoteDao.kt b/app/src/main/java/us/huseli/retain/data/NoteDao.kt index ff67c08..3b52cff 100644 --- a/app/src/main/java/us/huseli/retain/data/NoteDao.kt +++ b/app/src/main/java/us/huseli/retain/data/NoteDao.kt @@ -1,6 +1,7 @@ package us.huseli.retain.data import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Transaction @@ -13,14 +14,14 @@ import java.util.UUID @Dao interface NoteDao { - @Query("DELETE FROM note WHERE noteId IN (:ids)") - suspend fun delete(ids: Collection) + @Delete + suspend fun delete(notes: Collection) @Transaction suspend fun deleteTrashed() { - val ids = listDeletedIds() - insertDeletedNotes(ids.map { DeletedNote(it) }) - delete(ids) + val notes = listDeletedNotes() + insertDeletedNotes(notes.map { DeletedNote(it.id) }) + delete(notes) } @Query("SELECT * FROM note WHERE noteIsDeleted = 0 ORDER BY notePosition") @@ -45,9 +46,12 @@ interface NoteDao { @Query("SELECT * FROM note") suspend fun listAllCombos(): List - @Query("SELECT noteId FROM note WHERE noteIsDeleted = 1") + @Query("SELECT deletedNoteId FROM deletednote") suspend fun listDeletedIds(): List + @Query("SELECT * FROM Note WHERE noteIsDeleted = 1") + suspend fun listDeletedNotes(): List + @Query( """ UPDATE note SET notePosition = notePosition + 1 WHERE noteId != :id AND notePosition >= :position AND 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 8d70ee6..7aa16eb 100644 --- a/app/src/main/java/us/huseli/retain/data/NoteRepository.kt +++ b/app/src/main/java/us/huseli/retain/data/NoteRepository.kt @@ -13,6 +13,7 @@ import us.huseli.retain.Constants.IMAGE_SUBDIR import us.huseli.retain.LogInterface import us.huseli.retain.Logger import us.huseli.retain.data.entities.ChecklistItem +import us.huseli.retain.data.entities.ChecklistItemWithNote import us.huseli.retain.data.entities.Image import us.huseli.retain.data.entities.Note import us.huseli.retain.data.entities.NoteCombo @@ -47,13 +48,12 @@ class NoteRepository @Inject constructor( log("_imageDirObserver.onEvent($eventType, $path): finished") } catch (e: Exception) { log("_imageDirObserver.onEvent($eventType, $path): could not process: $e", level = Log.ERROR) - log(e.stackTraceToString(), level = Log.ERROR) } } } } - val checklistItems: Flow> = checklistItemDao.flowList() + val checklistItemsWithNote: Flow> = checklistItemDao.flowListWithNote() 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 index f86a841..0364d5a 100644 --- a/app/src/main/java/us/huseli/retain/data/SyncBackendRepository.kt +++ b/app/src/main/java/us/huseli/retain/data/SyncBackendRepository.kt @@ -5,9 +5,11 @@ import android.content.SharedPreferences import androidx.preference.PreferenceManager import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import us.huseli.retain.Constants @@ -16,9 +18,11 @@ 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.DropboxEngine import us.huseli.retain.syncbackend.Engine import us.huseli.retain.syncbackend.NextCloudEngine import us.huseli.retain.syncbackend.SFTPEngine +import us.huseli.retain.syncbackend.tasks.OperationTaskResult import us.huseli.retain.syncbackend.tasks.RemoveImagesTask import us.huseli.retain.syncbackend.tasks.SyncTask import us.huseli.retain.syncbackend.tasks.UploadNoteCombosTask @@ -27,10 +31,12 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton +@OptIn(ExperimentalCoroutinesApi::class) class SyncBackendRepository @Inject constructor( @ApplicationContext context: Context, private val nextCloudEngine: NextCloudEngine, private val sftpEngine: SFTPEngine, + private val dropboxEngine: DropboxEngine, override val logger: Logger, private val noteDao: NoteDao, private val database: Database, @@ -40,30 +46,44 @@ class SyncBackendRepository @Inject constructor( ) : 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 _syncBackend: MutableStateFlow = MutableStateFlow( + preferences.getString(PREF_SYNC_BACKEND, null)?.let { SyncBackend.valueOf(it) } ?: SyncBackend.NONE ) - private val engine: Engine? - get() = when (_syncBackend.value) { - SyncBackend.NEXTCLOUD -> nextCloudEngine - SyncBackend.SFTP -> sftpEngine - else -> null + private val engine = MutableStateFlow(null).apply { + ioScope.launch { + _syncBackend.collect { + value = syncBackendToEngine(it) + } } + } + private val onSaveListeners = mutableListOf<() -> Unit>() - val hasActiveTasks: Flow = engine?.hasActiveTasks ?: flowOf(false) + val isSyncing: Flow = engine.flatMapLatest { it?.isSyncing ?: flowOf(false) } val needsTesting = MutableStateFlow(true) val syncBackend = _syncBackend.asStateFlow() init { preferences.registerOnSharedPreferenceChangeListener(this) + engine.value = syncBackendToEngine(_syncBackend.value) ioScope.launch { sync() } } - fun removeImages(images: Collection) = engine?.let { RemoveImagesTask(it, images).run() } + private fun syncBackendToEngine(syncBackend: SyncBackend): Engine? = when (syncBackend) { + SyncBackend.NEXTCLOUD -> nextCloudEngine + SyncBackend.SFTP -> sftpEngine + SyncBackend.DROPBOX -> dropboxEngine + else -> null + } - suspend fun sync() { + fun addOnSaveListener(listener: () -> Unit) = onSaveListeners.add(listener) + + fun removeImages(images: Collection) = engine.value?.let { RemoveImagesTask(it, images).run() } + + fun save() = onSaveListeners.forEach { it.invoke() } + + private suspend fun _sync() { @Suppress("Destructure") - engine?.let { + engine.value?.let { SyncTask( engine = it, localCombos = noteDao.listAllCombos().map { combo -> @@ -78,26 +98,43 @@ class SyncBackendRepository @Inject constructor( }, localImageDir = imageDir, deletedNoteIds = noteDao.listDeletedIds(), - ).run() + ).run { result -> + if (!result.success) showError("Sync with ${it.backend.displayName} failed", result.exception) + } } } - suspend fun uploadNotes() { - engine?.let { + suspend fun sync() { + if (needsTesting.value) { + needsTesting.value = false + engine.value?.test { result -> + if (result.success) ioScope.launch { _sync() } + } + } else _sync() + } + + suspend fun uploadNotes(onResult: ((OperationTaskResult) -> Unit)? = null) { + engine.value?.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}") - } + ).run { result -> onResult?.invoke(result) } } } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - if (key == PREF_SYNC_BACKEND) - _syncBackend.value = preferences.getString(key, null)?.let { SyncBackend.valueOf(it) } + when (key) { + PREF_SYNC_BACKEND -> { + val value = preferences.getString(key, null)?.let { SyncBackend.valueOf(it) } ?: SyncBackend.NONE + if (value != _syncBackend.value) { + engine.value?.cancelTasks() + _syncBackend.value = value + needsTesting.value = true + } + } + } } } diff --git a/app/src/main/java/us/huseli/retain/data/entities/ChecklistItemWithNote.kt b/app/src/main/java/us/huseli/retain/data/entities/ChecklistItemWithNote.kt new file mode 100644 index 0000000..2dd83c5 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/data/entities/ChecklistItemWithNote.kt @@ -0,0 +1,8 @@ +package us.huseli.retain.data.entities + +import androidx.room.Embedded + +data class ChecklistItemWithNote( + @Embedded val checklistItem: ChecklistItem, + @Embedded val note: Note, +) diff --git a/app/src/main/java/us/huseli/retain/syncbackend/DropboxEngine.kt b/app/src/main/java/us/huseli/retain/syncbackend/DropboxEngine.kt new file mode 100644 index 0000000..45975ac --- /dev/null +++ b/app/src/main/java/us/huseli/retain/syncbackend/DropboxEngine.kt @@ -0,0 +1,301 @@ +package us.huseli.retain.syncbackend + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import com.dropbox.core.AccessErrorException +import com.dropbox.core.DbxRequestConfig +import com.dropbox.core.InvalidAccessTokenException +import com.dropbox.core.NetworkIOException +import com.dropbox.core.RateLimitException +import com.dropbox.core.android.Auth +import com.dropbox.core.oauth.DbxCredential +import com.dropbox.core.v2.DbxClientV2 +import com.dropbox.core.v2.files.CreateFolderErrorException +import com.dropbox.core.v2.files.DownloadErrorException +import com.dropbox.core.v2.files.FileMetadata +import com.dropbox.core.v2.files.FolderMetadata +import com.dropbox.core.v2.files.WriteMode +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import us.huseli.retain.BuildConfig +import us.huseli.retain.Constants.PREF_DROPBOX_CREDENTIAL +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.io.FileInputStream +import java.io.FileOutputStream +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DropboxEngine @Inject constructor( + @ApplicationContext context: Context, + ioScope: CoroutineScope, + override val logger: Logger, +) : Engine(context, ioScope), SharedPreferences.OnSharedPreferenceChangeListener { + override val backend: SyncBackend = SyncBackend.DROPBOX + + private val scopes = listOf( + "files.metadata.write", + "files.metadata.read", + "files.content.write", + "files.content.read", + "account_info.read", + ) + private val credential = MutableStateFlow(null) + private val requestConfig = DbxRequestConfig.newBuilder("retain/${BuildConfig.VERSION_NAME}").build() + private var isAwaitingResult = false + private var client: DbxClientV2 = DbxClientV2(requestConfig, "") + private val _isTesting = MutableStateFlow(false) + private val _accountEmail = MutableStateFlow("") + + val isTesting = _isTesting.asStateFlow() + val isAuthenticated = credential.map { it != null } + val accountEmail = _accountEmail.asStateFlow() + + init { + preferences.registerOnSharedPreferenceChangeListener(this) + preferences.getString(PREF_DROPBOX_CREDENTIAL, null)?.let { updateClient(it) } + status = + if (preferences.getString(PREF_SYNC_BACKEND, null) == SyncBackend.DROPBOX.name) STATUS_READY + else STATUS_DISABLED + + ioScope.launch { + syncBackend.collect { + if (it != backend) status = STATUS_DISABLED + else if (status == STATUS_DISABLED) status = STATUS_READY + } + } + } + + fun authenticate() { + Auth.startOAuth2PKCE(context, BuildConfig.dropboxAppKey, requestConfig, scopes) + isAwaitingResult = true + } + + fun revoke() { + ioScope.launch(Dispatchers.IO) { + try { + wrapRequest { client.auth().tokenRevoke() } + } catch (e: Exception) { + log("DropboxEnging.revoke(): $e", level = Log.ERROR) + } + } + preferences.edit().putString(PREF_DROPBOX_CREDENTIAL, null).apply() + } + + fun onResume() { + if (isAwaitingResult) { + val credential = Auth.getDbxCredential() + isAwaitingResult = false + if (credential != null) { + preferences + .edit() + .putString(PREF_DROPBOX_CREDENTIAL, DbxCredential.Writer.writeToString(credential)) + .apply() + } + } + } + + private fun exceptionToResult( + exception: Exception, + localFiles: List = emptyList(), + objects: List = emptyList() + ): OperationTaskResult { + val status = when (exception) { + is InvalidAccessTokenException -> TaskResult.Status.AUTH_ERROR + is NetworkIOException -> TaskResult.Status.CONNECT_ERROR + is AccessErrorException -> TaskResult.Status.AUTH_ERROR + else -> TaskResult.Status.OTHER_ERROR + } + return OperationTaskResult(status = status, exception = exception, localFiles = localFiles, objects = objects) + } + + private fun getAccountEmail() = ioScope.launch(Dispatchers.IO) { + try { + _accountEmail.value = wrapRequest { client.users().currentAccount.email } + } catch (e: Exception) { + showError("DropboxEngine.getAccountEmail()", e) + } + } + + private fun updateClient(credentialBody: String?) { + if (credentialBody == null) credential.value = null + else { + try { + credential.value = DbxCredential.Reader.readFully(credentialBody).also { + client = DbxClientV2(requestConfig, it) + getAccountEmail() + } + } catch (e: Exception) { + showError("DropboxEngine.updateClient()", e) + } + } + } + + private suspend fun wrapRequest(request: () -> T): T { + var retries = 0 + while (true) { + try { + return request() + } catch (e: Exception) { + if (e is RateLimitException) { + if (++retries == 5) throw e + else delay(e.backoffMillis) + } else throw e + } + } + } + + override fun createDir( + remoteDir: String, + onResult: (OperationTaskResult) -> Unit + ) = ioScope.launch(Dispatchers.IO) { + val okResult = OperationTaskResult(status = TaskResult.Status.OK, objects = listOf(remoteDir)) + + try { + wrapRequest { client.files().createFolderV2(remoteDir) } + onResult(okResult) + } catch (e: Exception) { + if (e is CreateFolderErrorException && e.errorValue.pathValue.isConflict) + onResult(okResult) + else + onResult(exceptionToResult(e, objects = listOf(remoteDir))) + } + } + + override fun downloadFile( + remotePath: String, + localFile: File, + onResult: (OperationTaskResult) -> Unit + ) = ioScope.launch(Dispatchers.IO) { + try { + val fileMeta: FileMetadata + + FileOutputStream(localFile).use { outputStream -> + fileMeta = wrapRequest { client.files().download(remotePath).download(outputStream) } + } + onResult( + OperationTaskResult( + status = TaskResult.Status.OK, + remoteFiles = listOf( + RemoteFile( + name = fileMeta.pathLower, + size = fileMeta.size, + isDirectory = false + ) + ), + localFiles = listOf(localFile), + ) + ) + } catch (e: Exception) { + if (e is DownloadErrorException && e.errorValue.pathValue.isNotFound) + onResult( + OperationTaskResult( + status = TaskResult.Status.PATH_NOT_FOUND, + exception = e, + localFiles = listOf(localFile) + ) + ) + else + onResult(exceptionToResult(e, localFiles = listOf(localFile))) + } + } + + override fun listFiles( + remoteDir: String, + filter: (RemoteFile) -> Boolean, + onResult: (OperationTaskResult) -> Unit + ) = ioScope.launch(Dispatchers.IO) { + try { + onResult( + OperationTaskResult( + status = TaskResult.Status.OK, + remoteFiles = wrapRequest { client.files().listFolder(remoteDir) }.entries.map { + RemoteFile( + name = it.pathLower, + size = (it as? FileMetadata)?.size ?: 0, + isDirectory = it is FolderMetadata, + ) + }.filter(filter) + ) + ) + } catch (e: Exception) { + onResult(exceptionToResult(e, objects = listOf(remoteDir))) + } + } + + override fun removeFile( + remotePath: String, + onResult: (OperationTaskResult) -> Unit + ) = ioScope.launch(Dispatchers.IO) { + try { + wrapRequest { client.files().deleteV2(remotePath) } + onResult(OperationTaskResult(status = TaskResult.Status.OK, objects = listOf(remotePath))) + } catch (e: Exception) { + onResult(exceptionToResult(e, objects = listOf(remotePath))) + } + } + + override fun uploadFile( + localFile: File, + remotePath: String, + mimeType: String?, + onResult: (OperationTaskResult) -> Unit + ) = ioScope.launch(Dispatchers.IO) { + try { + FileInputStream(localFile).use { inputStream -> + wrapRequest { + client.files() + .uploadBuilder(remotePath) + .withMode(WriteMode.OVERWRITE) + .uploadAndFinish(inputStream) + } + } + onResult( + OperationTaskResult( + status = TaskResult.Status.OK, + localFiles = listOf(localFile), + objects = listOf(remotePath), + ) + ) + } catch (e: Exception) { + onResult( + exceptionToResult( + exception = e, + localFiles = listOf(localFile), + objects = listOf(remotePath) + ) + ) + } + } + + override fun getAbsolutePath(vararg segments: String) = '/' + super.getAbsolutePath(*segments) + + override fun test(onResult: (TestTaskResult) -> Unit) { + _isTesting.value = true + super.test { result -> + _isTesting.value = false + onResult(result) + } + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + PREF_DROPBOX_CREDENTIAL -> updateClient(preferences.getString(key, null)) + PREF_SYNC_BACKEND -> updateSyncBackend() + } + } +} diff --git a/app/src/main/java/us/huseli/retain/syncbackend/Engine.kt b/app/src/main/java/us/huseli/retain/syncbackend/Engine.kt index e850a90..784e378 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/Engine.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/Engine.kt @@ -9,12 +9,10 @@ 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.Constants.PREF_SYNC_BACKEND import us.huseli.retain.Enums.SyncBackend import us.huseli.retain.InstantAdapter import us.huseli.retain.LogInterface @@ -27,14 +25,15 @@ 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 + protected val syncBackend: MutableStateFlow = MutableStateFlow( + preferences.getString(PREF_SYNC_BACKEND, null)?.let { SyncBackend.valueOf(it) } ?: SyncBackend.NONE + ) internal val gson: Gson = GsonBuilder() .registerTypeAdapter(Instant::class.java, InstantAdapter()) @@ -52,19 +51,20 @@ abstract class Engine(internal val context: Context, internal val ioScope: Corou 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 } - } + val isSyncing = MutableStateFlow(false) + + protected fun updateSyncBackend() { + syncBackend.value = + preferences.getString(PREF_SYNC_BACKEND, null)?.let { SyncBackend.valueOf(it) } ?: SyncBackend.NONE } 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 downloadFile(remotePath: String, localFile: File, onResult: (OperationTaskResult) -> Unit): Any abstract fun listFiles( remoteDir: String, - filter: ((RemoteFile) -> Boolean)? = null, + filter: (RemoteFile) -> Boolean, onResult: (OperationTaskResult) -> Unit ): Any @@ -80,36 +80,29 @@ abstract class Engine(internal val context: Context, internal val ioScope: Corou log("Waiting tasks: ${waitingTasks.map { it.javaClass.simpleName }}") } + fun cancelTasks() { + tasks.value.forEach { task -> task.cancel() } + } + open fun getAbsolutePath(vararg segments: String) = segments.joinToString("/") { it.trim('/') } - fun registerTask(task: Task<*, *>, triggerStatus: Int, callback: () -> Unit) { + fun registerTask(task: Task<*, *>, triggerStatus: Int, startTask: () -> Unit) { log( - "registerTask: task=${task.javaClass.simpleName}, triggerStatus=$triggerStatus, status=$status", + message = "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() + if (status >= triggerStatus && runningNonMetaTasks.size < 3) startTask() else ioScope.launch { while (status < triggerStatus || runningNonMetaTasks.size >= 3) delay(1_000) - callback() + startTask() } 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. + open fun test(onResult: (TestTaskResult) -> Unit) { + if (status > STATUS_TESTING) { status = STATUS_TESTING TestTask(this).run(STATUS_TESTING) { result -> status = when (result.status) { @@ -117,29 +110,9 @@ abstract class Engine(internal val context: Context, internal val ioScope: Corou 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 - } - } + onResult(result) } - } 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 { diff --git a/app/src/main/java/us/huseli/retain/syncbackend/NextCloudEngine.kt b/app/src/main/java/us/huseli/retain/syncbackend/NextCloudEngine.kt index ab159d4..db77153 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/NextCloudEngine.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/NextCloudEngine.kt @@ -18,7 +18,8 @@ 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 kotlinx.coroutines.launch +import us.huseli.retain.Constants.DEFAULT_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 @@ -69,7 +70,7 @@ class NextCloudEngine @Inject constructor( } } - private var baseDir = NEXTCLOUD_BASE_DIR + private var baseDir = DEFAULT_NEXTCLOUD_BASE_DIR set(value) { if (field != value.trimEnd('/')) field = value.trimEnd('/') } @@ -87,15 +88,24 @@ class NextCloudEngine @Inject constructor( 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 + baseDir = + preferences.getString(PREF_NEXTCLOUD_BASE_DIR, DEFAULT_NEXTCLOUD_BASE_DIR) ?: DEFAULT_NEXTCLOUD_BASE_DIR status = if (preferences.getString(PREF_SYNC_BACKEND, null) == SyncBackend.NEXTCLOUD.name) STATUS_READY else STATUS_DISABLED preferences.registerOnSharedPreferenceChangeListener(this) + + ioScope.launch { + syncBackend.collect { + if (it != backend) status = STATUS_DISABLED + else if (status == STATUS_DISABLED) status = STATUS_READY + } + } } private fun resultToStatus(result: RemoteOperationResult<*>): TaskResult.Status = if (result.isSuccess) TaskResult.Status.OK + else if (result.code == RemoteOperationResult.ResultCode.FILE_NOT_FOUND) TaskResult.Status.PATH_NOT_FOUND else if ( result.code == RemoteOperationResult.ResultCode.UNAUTHORIZED || result.code == RemoteOperationResult.ResultCode.FORBIDDEN @@ -133,7 +143,7 @@ class NextCloudEngine @Inject constructor( isEnabled: Boolean? = null ) { log( - "updateClient: uri=$uri, username=$username, password=$password, isEnabled=$isEnabled", + message = "updateClient: uri=$uri, username=$username, password=$password, isEnabled=$isEnabled", level = Log.DEBUG ) if (uri != null) client.baseUri = uri @@ -153,7 +163,7 @@ class NextCloudEngine @Inject constructor( username: String, password: String, baseDir: String, - onResult: ((TestTaskResult) -> Unit)? = null + onResult: (TestTaskResult) -> Unit ) { this.uri = uri this.username = username @@ -163,7 +173,7 @@ class NextCloudEngine @Inject constructor( _isTesting.value = true test { result -> _isTesting.value = false - onResult?.invoke(result) + onResult(result) } } } @@ -176,12 +186,16 @@ class NextCloudEngine @Inject constructor( onResult(castResult(result, status = status, objects = listOf(remoteDir))) } - override fun downloadFile(remotePath: String, onResult: (OperationTaskResult) -> Unit) = + override fun downloadFile(remotePath: String, localFile: File, onResult: (OperationTaskResult) -> Unit) = executeRemoteOperation(DownloadFileRemoteOperation(remotePath, tempDirDown.absolutePath + '/')) { result -> + val tmpFile = File(tempDirDown, remotePath) + if (result.isSuccess && tmpFile.absolutePath != localFile.absolutePath) { + File(tempDirDown, remotePath).renameTo(localFile) + } onResult( castResult( result, - localFiles = listOf(File(tempDirDown, remotePath)), + localFiles = listOf(localFile), objects = listOf(remotePath) ) ) @@ -193,7 +207,7 @@ class NextCloudEngine @Inject constructor( @Suppress("DEPRECATION") override fun listFiles( remoteDir: String, - filter: ((RemoteFile) -> Boolean)?, + filter: (RemoteFile) -> Boolean, onResult: (OperationTaskResult) -> Unit ) { executeRemoteOperation(ReadFolderRemoteOperation(remoteDir)) { result -> @@ -203,7 +217,7 @@ class NextCloudEngine @Inject constructor( remoteFiles = result.data .filterIsInstance() .map { RemoteFile(it.remotePath, it.length, it.mimeType == "DIR") } - .filter(filter ?: { true }) + .filter(filter) ) ) } @@ -233,12 +247,9 @@ class NextCloudEngine @Inject constructor( 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 - } + PREF_NEXTCLOUD_BASE_DIR -> baseDir = + preferences.getString(key, DEFAULT_NEXTCLOUD_BASE_DIR) ?: DEFAULT_NEXTCLOUD_BASE_DIR + PREF_SYNC_BACKEND -> updateSyncBackend() } } } diff --git a/app/src/main/java/us/huseli/retain/syncbackend/SFTPEngine.kt b/app/src/main/java/us/huseli/retain/syncbackend/SFTPEngine.kt index aff6ee7..d081b82 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/SFTPEngine.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/SFTPEngine.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.SharedPreferences import android.util.Log import com.jcraft.jsch.ChannelSftp +import com.jcraft.jsch.ChannelSftp.SSH_FX_NO_SUCH_FILE import com.jcraft.jsch.JSch import com.jcraft.jsch.JSchException import com.jcraft.jsch.SftpException @@ -13,13 +14,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import us.huseli.retain.Constants.DEFAULT_SFTP_BASE_DIR 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 @@ -43,7 +44,8 @@ class SFTPEngine @Inject constructor( 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 baseDir = + MutableStateFlow(preferences.getString(PREF_SFTP_BASE_DIR, DEFAULT_SFTP_BASE_DIR) ?: DEFAULT_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)) @@ -73,6 +75,12 @@ class SFTPEngine @Inject constructor( else STATUS_DISABLED preferences.registerOnSharedPreferenceChangeListener(this) jsch.setKnownHosts(knownHostsFile.absolutePath) + ioScope.launch { + syncBackend.collect { + if (it != backend) status = STATUS_DISABLED + else if (status == STATUS_DISABLED) status = STATUS_READY + } + } } fun approveKey() { @@ -93,7 +101,9 @@ class SFTPEngine @Inject constructor( is ConnectException -> TaskResult.Status.CONNECT_ERROR else -> TaskResult.Status.AUTH_ERROR } - } else TaskResult.Status.OTHER_ERROR + } else if (exception is SftpException && exception.id == SSH_FX_NO_SUCH_FILE) + TaskResult.Status.PATH_NOT_FOUND + else TaskResult.Status.OTHER_ERROR return OperationTaskResult(status = status, exception = exception, objects = objects) } @@ -155,16 +165,19 @@ class SFTPEngine @Inject constructor( ) } - override fun downloadFile(remotePath: String, onResult: (OperationTaskResult) -> Unit) = ioScope.launch { + override fun downloadFile( + remotePath: String, + localFile: File, + 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) - ) - ) + val tmpFile = File(tempDirDown, remotePath.split('/').last()) + if (result.success && tmpFile.absolutePath != localFile.absolutePath) { + tmpFile.renameTo(localFile) + } + onResult(result.copy(localFiles = listOf(localFile), objects = listOf(remotePath))) }, ) } @@ -174,7 +187,7 @@ class SFTPEngine @Inject constructor( override fun listFiles( remoteDir: String, - filter: ((RemoteFile) -> Boolean)?, + filter: (RemoteFile) -> Boolean, onResult: (OperationTaskResult) -> Unit ) = ioScope.launch { try { @@ -185,7 +198,7 @@ class SFTPEngine @Inject constructor( status = TaskResult.Status.OK, remoteFiles = lsResult .map { entry -> RemoteFile(entry.filename, entry.attrs.size, entry.attrs.isDir) } - .filter(filter ?: { true }), + .filter(filter), objects = lsResult, ) ) @@ -218,16 +231,13 @@ class SFTPEngine @Inject constructor( 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_BASE_DIR -> baseDir.value = + preferences.getString(key, DEFAULT_SFTP_BASE_DIR) ?: DEFAULT_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 - } + PREF_SYNC_BACKEND -> updateSyncBackend() } } } 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 index 89b3001..81d2dea 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadFileTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadFileTask.kt @@ -4,21 +4,21 @@ import us.huseli.retain.syncbackend.Engine import java.io.File /** Down: 1 arbitrary file */ -abstract class DownloadFileTask( +open class DownloadFileTask( engine: ET, protected val remotePath: String, + protected val localFile: File, ) : 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) + engine.downloadFile(remotePath, localFile) { result -> + handleDownloadedFile(localFile, result, onResult) } } - abstract fun handleDownloadedFile(file: File, result: OperationTaskResult, onResult: (RT) -> Unit) + open fun handleDownloadedFile(file: File, result: OperationTaskResult, onResult: (RT) -> Unit) { + @Suppress("UNCHECKED_CAST") + onResult(result as RT) + } } 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 index 2ac4ba7..b046bf8 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadImagesTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadImagesTask.kt @@ -5,26 +5,12 @@ 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 -) { +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) { @@ -33,7 +19,7 @@ class DownloadImagesTask( } } - override fun getChildTask(obj: Image) = DownloadImageTask( + override fun getChildTask(obj: Image) = DownloadFileTask( 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 index 1742f03..2196f6f 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadListJSONTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadListJSONTask.kt @@ -18,10 +18,12 @@ class DownloadListJSONTaskResult( abstract class DownloadListJSONTask( engine: ET, - remotePath: String + remotePath: String, + localFile: File ) : DownloadFileTask>( engine = engine, remotePath = remotePath, + localFile = localFile, ) { private var _finished = false 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 index f6f4060..83093ab 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadNoteCombosJSONTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/DownloadNoteCombosJSONTask.kt @@ -4,6 +4,7 @@ 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.io.File import java.util.UUID class DownloadNoteCombosJSONTask( @@ -11,7 +12,8 @@ class DownloadNoteCombosJSONTask( private val deletedNoteIds: Collection ) : DownloadListJSONTask( engine = engine, - remotePath = engine.getAbsolutePath(SYNCBACKEND_JSON_SUBDIR, "noteCombos.json") + remotePath = engine.getAbsolutePath(SYNCBACKEND_JSON_SUBDIR, "noteCombos.json"), + localFile = File(engine.tempDirDown, "noteCombos.json"), ) { override fun deserialize(json: String): List? { val listType = object : TypeToken>() {} 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 index 173ac47..e36f297 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/ListFilesListTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/ListFilesListTask.kt @@ -6,7 +6,7 @@ import us.huseli.retain.syncbackend.Engine.Companion.STATUS_OK abstract class ListFilesListTask>( engine: ET, remoteDir: String, - filter: (RemoteFile) -> Boolean + filter: (RemoteFile) -> Boolean, ) : ListFilesTask(engine, remoteDir, filter) { private var onEachCallback: ((RemoteFile, CRT) -> Unit)? = null protected val successfulRemoteFiles = mutableListOf() 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 index 0a74158..9213e0d 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/ListFilesTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/ListFilesTask.kt @@ -6,7 +6,7 @@ import us.huseli.retain.syncbackend.Engine abstract class ListFilesTask( engine: ET, private val remoteDir: String, - protected val filter: ((RemoteFile) -> Boolean)? = null, + protected val filter: (RemoteFile) -> Boolean, ) : OperationTask(engine) { protected val remoteFiles = mutableListOf() 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 index 4e3a43d..0697f9b 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveFileTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveFileTask.kt @@ -5,6 +5,6 @@ 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 val successMessageString = "Successfully removed $remotePath" 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 index 4e2422b..b5393ac 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveImagesTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveImagesTask.kt @@ -1,6 +1,6 @@ package us.huseli.retain.syncbackend.tasks -import us.huseli.retain.Constants +import us.huseli.retain.Constants.SYNCBACKEND_IMAGE_SUBDIR import us.huseli.retain.data.entities.Image import us.huseli.retain.syncbackend.Engine @@ -17,6 +17,6 @@ class RemoveImagesTask(engine: ET, images: Collection) : override fun getChildTask(obj: Image) = RemoveFileTask( engine = engine, - remotePath = engine.getAbsolutePath(Constants.SYNCBACKEND_IMAGE_SUBDIR, obj.filename), + remotePath = engine.getAbsolutePath(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 index 03224c6..6f8281c 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveOrphanImagesTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/RemoveOrphanImagesTask.kt @@ -1,12 +1,12 @@ package us.huseli.retain.syncbackend.tasks -import us.huseli.retain.Constants +import us.huseli.retain.Constants.SYNCBACKEND_IMAGE_SUBDIR import us.huseli.retain.syncbackend.Engine class RemoveOrphanImagesTask(engine: ET, private val keep: List) : ListFilesListTask>( engine = engine, - remoteDir = engine.getAbsolutePath(Constants.SYNCBACKEND_IMAGE_SUBDIR), + remoteDir = engine.getAbsolutePath(SYNCBACKEND_IMAGE_SUBDIR), filter = { (name, _, isDirectory) -> !isDirectory && !keep.contains(name.split("/").last()) }, ) { override val failOnUnsuccessfulChildTask = false diff --git a/app/src/main/java/us/huseli/retain/syncbackend/tasks/SyncTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/SyncTask.kt index 5ef4487..d20fa3a 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/SyncTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/SyncTask.kt @@ -5,6 +5,11 @@ import us.huseli.retain.syncbackend.Engine import java.io.File import java.util.UUID +data class TaskLog(val simpleName: String, val totalCount: Int = 1, var finishedCount: Int = 0) { + val isFinished: Boolean + get() = finishedCount >= totalCount +} + class SyncTask( engine: ET, private val localCombos: Collection, @@ -12,95 +17,82 @@ class SyncTask( private val onRemoteComboUpdated: (NoteCombo) -> Unit, private val localImageDir: File, ) : Task(engine = engine) { - private val images = mutableListOf(*localCombos.map { it.images }.flatten().toTypedArray()) - private var downloadNoteCombosJSONTaskFinished = false - private var downloadMissingImagesTaskFinished = false - private var downloadNoteImagesTasksFinished = 0 - private var uploadNoteCombosTaskFinished = false - private var uploadMissingImagesTaskFinished = false - private var removeOrphanImagesTaskFinished = false + private var isCancelled = false private var remoteUpdatedCombos: List = emptyList() + private val finishedTasks = listOf( + TaskLog(DownloadImagesTask::class.java.simpleName, 2), + TaskLog(DownloadNoteCombosJSONTask::class.java.simpleName), + TaskLog(UploadNoteCombosTask::class.java.simpleName), + TaskLog(UploadMissingImagesTask::class.java.simpleName), + TaskLog(RemoveOrphanImagesTask::class.java.simpleName), + ) + private var onResult: (TaskResult) -> Unit = {} - private fun isFinished() = - downloadNoteImagesTasksFinished == remoteUpdatedCombos.size && - downloadNoteCombosJSONTaskFinished && - uploadNoteCombosTaskFinished && - uploadMissingImagesTaskFinished && - removeOrphanImagesTaskFinished && - downloadMissingImagesTaskFinished - - private fun notifyIfFinished(onResult: (TaskResult) -> Unit) { - if (isFinished()) onResult(TaskResult(status = TaskResult.Status.OK)) + private fun runChildTask( + task: Task, + onChildResult: ((CRT) -> Unit)? = null, + ) { + if (!isCancelled) { + task.run { result -> + finishedTasks.find { it.simpleName == task.javaClass.simpleName }?.apply { finishedCount++ } + if (result.status != TaskResult.Status.OK && result.status != TaskResult.Status.PATH_NOT_FOUND) { + isCancelled = true + engine.isSyncing.value = false + onResult(result) + } else { + onChildResult?.invoke(result) + if (finishedTasks.all { it.isFinished }) { + engine.isSyncing.value = false + onResult(result) + } + } + } + } } override fun start(onResult: (TaskResult) -> Unit) { - DownloadImagesTask(engine, images.filter { !File(localImageDir, it.filename).exists() }).run { - downloadMissingImagesTaskFinished = true - notifyIfFinished(onResult) - } + this.onResult = onResult + engine.isSyncing.value = true + val images = localCombos.flatMap { it.images }.toMutableList() - DownloadNoteCombosJSONTask(engine, deletedNoteIds).run { downTaskResult -> - val remoteCombos = downTaskResult.objects - downloadNoteCombosJSONTaskFinished = true + runChildTask(DownloadImagesTask(engine, images.filter { !File(localImageDir, it.filename).exists() })) + runChildTask(DownloadNoteCombosJSONTask(engine, deletedNoteIds)) { downTaskResult -> // All notes on remote that either don't exist locally, or // have a newer timestamp than their local counterparts: @Suppress("destructure") - remoteUpdatedCombos = remoteCombos.filter { remote -> + remoteUpdatedCombos = downTaskResult.objects.filter { remote -> localCombos .find { it.note.id == remote.note.id } ?.let { local -> local.note < remote.note } ?: true } - remoteUpdatedCombos.forEach { combo -> images.addAll(combo.images) + // This will save combo to DB: onRemoteComboUpdated(combo) - DownloadImagesTask(engine, combo.images).run( - onEachCallback = { _, result -> - if (!result.success) logError("Failed to download image: ${result.message}") - }, - onReadyCallback = { - downloadNoteImagesTasksFinished++ - notifyIfFinished(onResult) - } - ) } - if (remoteUpdatedCombos.isNotEmpty()) { log( - message = "${remoteUpdatedCombos.size} new or updated notes synced from Nextcloud.", + message = "${remoteUpdatedCombos.size} new or updated notes synced from ${engine.backend.displayName}.", showInSnackbar = true, ) } + runChildTask(DownloadImagesTask(engine, remoteUpdatedCombos.flatMap { it.images })) // 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: ${result.message}") - uploadNoteCombosTaskFinished = true - notifyIfFinished(onResult) - } + runChildTask(UploadNoteCombosTask(engine, combos)) // 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: ${result.message}") - uploadMissingImagesTaskFinished = true - notifyIfFinished(onResult) - } + runChildTask(UploadMissingImagesTask(engine, images)) // Delete any orphan image files, both locally and on Nextcloud: localImageDir.listFiles()?.forEach { file -> if (!imageFilenames.contains(file.name)) file.delete() } - RemoveOrphanImagesTask(engine, keep = imageFilenames).run { - removeOrphanImagesTaskFinished = true - notifyIfFinished(onResult) - } - - notifyIfFinished(onResult) + runChildTask(RemoveOrphanImagesTask(engine, keep = imageFilenames)) } } } 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 index 57873dc..1028e01 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/Task.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/Task.kt @@ -13,7 +13,7 @@ open class TaskResult( open val message: String? = null, open val exception: Exception? = null, ) { - enum class Status { OK, UNKNOWN_HOST, CONNECT_ERROR, AUTH_ERROR, OTHER_ERROR } + enum class Status { OK, UNKNOWN_HOST, CONNECT_ERROR, AUTH_ERROR, PATH_NOT_FOUND, OTHER_ERROR } val success get() = status == Status.OK @@ -27,7 +27,8 @@ open class TaskResult( abstract class Task(protected val engine: ET) : LogInterface { override val logger: Logger = engine.logger - private var _status = MutableStateFlow(STATUS_WAITING) + private val _status = MutableStateFlow(STATUS_WAITING) + private val isCancelled = MutableStateFlow(false) private val onFinishedListeners = mutableListOf<(RT) -> Unit>() protected var triggerStatus: Int = STATUS_OK @@ -42,24 +43,32 @@ abstract class Task(protected val engine: ET) : Lo fun addOnFinishedListener(listener: (RT) -> Unit) = onFinishedListeners.add(listener) + fun cancel() { + isCancelled.value = true + } + 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) + if (isCancelled.value) { + _status.value = STATUS_CANCELLED + } else { + _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) } } - onFinishedListeners.forEach { it.invoke(result) } } } } @@ -67,6 +76,7 @@ abstract class Task(protected val engine: ET) : Lo companion object { const val STATUS_WAITING = 0 const val STATUS_RUNNING = 1 - const val STATUS_FINISHED = 2 + const val STATUS_CANCELLED = 2 + const val STATUS_FINISHED = 3 } } 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 index 8c6cea6..3f76d47 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/TestTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/TestTask.kt @@ -21,12 +21,17 @@ class TestTaskResult( override fun hashCode() = timestamp.hashCode() fun getErrorMessage(context: Context): String { - return when (status) { + var message = 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) + else -> context.getString(R.string.an_error_occurred) } + if (this.message != null || exception != null) { + message += "\n\n" + context.getString(R.string.the_error_was) + ' ' + message += this.message ?: exception.toString() + } + return message } companion object { 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 index 5d117b9..71384b6 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadFileTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadFileTask.kt @@ -10,7 +10,7 @@ open class UploadFileTask( private val localFile: File, private val mimeType: String? = null, ) : OperationTask(engine) { - override val successMessageString = "Successfully saved $localFile to $remotePath on Nextcloud" + override val successMessageString = "Successfully saved $localFile to $remotePath" override fun start(onResult: (OperationTaskResult) -> Unit) { if (!localFile.isFile) 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 deleted file mode 100644 index b7a4f24..0000000 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadImageTask.kt +++ /dev/null @@ -1,13 +0,0 @@ -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/syncbackend/tasks/UploadMissingImagesTask.kt b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadMissingImagesTask.kt index 838ef5e..8b14949 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadMissingImagesTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadMissingImagesTask.kt @@ -1,21 +1,18 @@ package us.huseli.retain.syncbackend.tasks import us.huseli.retain.Constants +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 /** * 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: ET, - private val images: Collection -) : ListFilesTask( - engine, - engine.getAbsolutePath(Constants.SYNCBACKEND_IMAGE_SUBDIR) -) { +class UploadMissingImagesTask(engine: ET, private val images: Collection) : + ListFilesTask(engine, engine.getAbsolutePath(SYNCBACKEND_IMAGE_SUBDIR), { true }) { private var processedFiles = 0 private var missingImages = mutableListOf() @@ -39,8 +36,13 @@ class UploadMissingImagesTask( } else images.toList() ) + @Suppress("Destructure") missingImages.forEach { image -> - UploadImageTask(engine, image).run(triggerStatus) { childResult -> + UploadFileTask( + engine = engine, + remotePath = engine.getAbsolutePath(SYNCBACKEND_IMAGE_SUBDIR, image.filename), + localFile = File(File(engine.context.filesDir, Constants.IMAGE_SUBDIR), image.filename), + ).run(triggerStatus) { childResult -> processedFiles++ if (childResult.hasNetworkError || processedFiles == missingImages.size) onResult(childResult) } 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 index df43c21..decdae0 100644 --- a/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadNoteCombosTask.kt +++ b/app/src/main/java/us/huseli/retain/syncbackend/tasks/UploadNoteCombosTask.kt @@ -3,7 +3,7 @@ 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.Constants.SYNCBACKEND_JSON_SUBDIR import us.huseli.retain.data.entities.NoteCombo import us.huseli.retain.syncbackend.Engine import java.io.File @@ -14,7 +14,7 @@ class UploadNoteCombosTask( private val combos: Collection ) : OperationTask(engine) { private val filename = "noteCombos.json" - private val remotePath = engine.getAbsolutePath(Constants.SYNCBACKEND_JSON_SUBDIR, filename) + private val remotePath = engine.getAbsolutePath(SYNCBACKEND_JSON_SUBDIR, filename) private val localFile = File(engine.tempDirUp, filename).apply { deleteOnExit() } override fun start(onResult: (OperationTaskResult) -> Unit) { diff --git a/app/src/main/java/us/huseli/retain/ui/theme/RetainColor.kt b/app/src/main/java/us/huseli/retain/ui/theme/RetainColor.kt index c1b0521..cc70bc1 100644 --- a/app/src/main/java/us/huseli/retain/ui/theme/RetainColor.kt +++ b/app/src/main/java/us/huseli/retain/ui/theme/RetainColor.kt @@ -1,5 +1,3 @@ -@file:Suppress("unused") - package us.huseli.retain.ui.theme import android.content.Context @@ -7,202 +5,11 @@ import android.content.res.Configuration import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import us.huseli.retaintheme.ui.theme.RetainColorDark +import us.huseli.retaintheme.ui.theme.RetainColorLight +import us.huseli.retaintheme.ui.theme.RetainColorScheme import kotlin.math.max -interface RetainColorScheme { - val Background: Color - val Error: Color - val ErrorContainer: Color - val InverseOnSurface: Color - val InversePrimary: Color - val InverseSurface: Color - val OnBackground: Color - val OnError: Color - val OnErrorContainer: Color - val OnPrimary: Color - val OnPrimaryContainer: Color - val OnPrimaryFixed: Color - val OnPrimaryFixedVariant: Color - val OnSecondary: Color - val OnSecondaryContainer: Color - val OnSecondaryFixed: Color - val OnSecondaryFixedVariant: Color - val OnSurface: Color - val OnSurfaceVariant: Color - val OnTertiary: Color - val OnTertiaryContainer: Color - val OnTertiaryFixed: Color - val OnTertiaryFixedVariant: Color - val Outline: Color - val OutlineVariant: Color - val Primary: Color - val PrimaryContainer: Color - val PrimaryFixed: Color - val PrimaryFixedDim: Color - val Scrim: Color - val Secondary: Color - val SecondaryContainer: Color - val SecondaryFixed: Color - val SecondaryFixedDim: Color - val Shadow: Color - val Surface: Color - val SurfaceBright: Color - val SurfaceContainer: Color - val SurfaceContainerHigh: Color - val SurfaceContainerHighest: Color - val SurfaceContainerLow: Color - val SurfaceContainerLowest: Color - val SurfaceDim: Color - val SurfaceTint: Color - val SurfaceVariant: Color - val Tertiary: Color - val TertiaryContainer: Color - val TertiaryFixed: Color - val TertiaryFixedDim: Color - - val Brown: Color - val Purple: Color - val Cerulean: Color - val Gray: Color - val Pink: Color - val Blue: Color - val Red: Color - val Yellow: Color - val Green: Color - val Teal: Color - val Orange: Color -} - -object RetainColorDark : RetainColorScheme { - override val Background = Color(0xFF191C1D) - override val Error = Color(0xFFFFB4AB) - override val ErrorContainer = Color(0xFF93000A) - override val InverseOnSurface = Color(0xFF191C1D) - override val InversePrimary = Color(0xFF00658F) - override val InverseSurface = Color(0xFFE1E3E3) - override val OnBackground = Color(0xFFE1E3E3) - override val OnError = Color(0xFF690005) - override val OnErrorContainer = Color(0xFFFFDAD6) - override val OnPrimary = Color(0xFF00344C) - override val OnPrimaryContainer = Color(0xFFC7E7FF) - override val OnPrimaryFixed = Color(0xFF001E2E) - override val OnPrimaryFixedVariant = Color(0xFF004C6C) - override val OnSecondary = Color(0xFF21323E) - override val OnSecondaryContainer = Color(0xFFD2E5F5) - override val OnSecondaryFixed = Color(0xFF0B1D29) - override val OnSecondaryFixedVariant = Color(0xFF374955) - override val OnSurface = Color(0xFFC4C7C7) - override val OnSurfaceVariant = Color(0xFFBFC8CA) - override val OnTertiary = Color(0xFF293500) - override val OnTertiaryContainer = Color(0xFFD4ED7F) - override val OnTertiaryFixed = Color(0xFF171E00) - override val OnTertiaryFixedVariant = Color(0xFF3D4D00) - override val Outline = Color(0xFF899294) - override val OutlineVariant = Color(0xFF3F484A) - override val Primary = Color(0xFF85CFFF) - override val PrimaryContainer = Color(0xFF004C6C) - override val PrimaryFixed = Color(0xFFC7E7FF) - override val PrimaryFixedDim = Color(0xFF85CFFF) - override val Scrim = Color(0xFF000000) - override val Secondary = Color(0xFFB6C9D8) - override val SecondaryContainer = Color(0xFF374955) - override val SecondaryFixed = Color(0xFFD2E5F5) - override val SecondaryFixedDim = Color(0xFFB6C9D8) - override val Shadow = Color(0xFF000000) - override val Surface = Color(0xFF101415) - override val SurfaceBright = Color(0xFF363A3A) - override val SurfaceContainer = Color(0xFF1D2021) - override val SurfaceContainerHigh = Color(0xFF272B2B) - override val SurfaceContainerHighest = Color(0xFF323536) - override val SurfaceContainerLow = Color(0xFF191C1D) - override val SurfaceContainerLowest = Color(0xFF0B0F0F) - override val SurfaceDim = Color(0xFF101415) - override val SurfaceTint = Color(0xFF85CFFF) - override val SurfaceVariant = Color(0xFF3F484A) - override val Tertiary = Color(0xFFB8D166) - override val TertiaryContainer = Color(0xFF3D4D00) - override val TertiaryFixed = Color(0xFFD4ED7F) - override val TertiaryFixedDim = Color(0xFFB8D166) - - override val Brown = Color(0xff4b443a) - override val Purple = Color(0xff472e5b) - override val Cerulean = Color(0xff284255) - override val Gray = Color(0xff232427) - override val Pink = Color(0xff6c394f) - override val Blue = Color(0xff256377) - override val Red = Color(0xff77172e) - override val Yellow = Color(0xff7c4a03) - override val Green = Color(0xff264d3b) - override val Teal = Color(0xff0c625d) - override val Orange = Color(0xff692b17) -} - -object RetainColorLight : RetainColorScheme { - override val Background = Color(0xFFFAFDFD) - override val Error = Color(0xFFBA1A1A) - override val ErrorContainer = Color(0xFFFFDAD6) - override val InverseOnSurface = Color(0xFFEFF1F1) - override val InversePrimary = Color(0xFF85CFFF) - override val InverseSurface = Color(0xFF2E3132) - override val OnBackground = Color(0xFF191C1D) - override val OnError = Color(0xFFFFFFFF) - override val OnErrorContainer = Color(0xFF410002) - override val OnPrimary = Color(0xFFFFFFFF) - override val OnPrimaryContainer = Color(0xFF001E2E) - override val OnPrimaryFixed = Color(0xFF001E2E) - override val OnPrimaryFixedVariant = Color(0xFF004C6C) - override val OnSecondary = Color(0xFFFFFFFF) - override val OnSecondaryContainer = Color(0xFF0B1D29) - override val OnSecondaryFixed = Color(0xFF0B1D29) - override val OnSecondaryFixedVariant = Color(0xFF374955) - override val OnSurface = Color(0xFF191C1D) - override val OnSurfaceVariant = Color(0xFF3F484A) - override val OnTertiary = Color(0xFFFFFFFF) - override val OnTertiaryContainer = Color(0xFF171E00) - override val OnTertiaryFixed = Color(0xFF171E00) - override val OnTertiaryFixedVariant = Color(0xFF3D4D00) - override val Outline = Color(0xFF6F797A) - override val OutlineVariant = Color(0xFFBFC8CA) - override val Primary = Color(0xFF00658F) - override val PrimaryContainer = Color(0xFFC7E7FF) - override val PrimaryFixed = Color(0xFFC7E7FF) - override val PrimaryFixedDim = Color(0xFF85CFFF) - override val Scrim = Color(0xFF000000) - override val Secondary = Color(0xFF4F616E) - override val SecondaryContainer = Color(0xFFD2E5F5) - override val SecondaryFixed = Color(0xFFD2E5F5) - override val SecondaryFixedDim = Color(0xFFB6C9D8) - override val Shadow = Color(0xFF000000) - override val Surface = Color(0xFFF8FAFA) - override val SurfaceBright = Color(0xFFF8FAFA) - override val SurfaceContainer = Color(0xFFECEEEF) - override val SurfaceContainerHigh = Color(0xFFE6E8E9) - override val SurfaceContainerHighest = Color(0xFFE1E3E3) - override val SurfaceContainerLow = Color(0xFFF2F4F4) - override val SurfaceContainerLowest = Color(0xFFFFFFFF) - override val SurfaceDim = Color(0xFFD8DADB) - override val SurfaceTint = Color(0xFF00658F) - override val SurfaceVariant = Color(0xFFDBE4E6) - override val Tertiary = Color(0xFF526600) - override val TertiaryContainer = Color(0xFFD4ED7F) - override val TertiaryFixed = Color(0xFFD4ED7F) - override val TertiaryFixedDim = Color(0xFFB8D166) - - override val Brown = Color(0xffe9e3d4) - override val Purple = Color(0xffd3bfdb) - override val Cerulean = Color(0xffaeccdc) - override val Gray = Color(0xffefeff1) - override val Pink = Color(0xfff6e2dd) - override val Blue = Color(0xffd4e4ed) - override val Red = Color(0xfffaafa8) - override val Yellow = Color(0xfffff8b8) - override val Green = Color(0xffe2f6d4) - override val Teal = Color(0xffb4ddd3) - override val Orange = Color(0xfff39f76) -} - -val seed = Color(0xFF4E6C81) - val noteColors: (RetainColorScheme) -> Map = { colorScheme -> mapOf( "DEFAULT" to colorScheme.Background, diff --git a/app/src/main/java/us/huseli/retain/ui/theme/Theme.kt b/app/src/main/java/us/huseli/retain/ui/theme/Theme.kt index f67e57d..87b6934 100644 --- a/app/src/main/java/us/huseli/retain/ui/theme/Theme.kt +++ b/app/src/main/java/us/huseli/retain/ui/theme/Theme.kt @@ -4,119 +4,17 @@ import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat -import com.google.accompanist.systemuicontroller.rememberSystemUiController - -private val LightColors = lightColorScheme( - primary = RetainColorLight.Primary, - onPrimary = RetainColorLight.OnPrimary, - primaryContainer = RetainColorLight.PrimaryContainer, - onPrimaryContainer = RetainColorLight.OnPrimaryContainer, - // primaryFixed = RetainColorLight.PrimaryFixed, - // onPrimaryFixed = RetainColorLight.OnPrimaryFixed, - // primaryFixedDim = RetainColorLight.PrimaryFixedDim, - // onPrimaryFixedVariant = RetainColorLight.OnPrimaryFixedVariant, - secondary = RetainColorLight.Secondary, - onSecondary = RetainColorLight.OnSecondary, - secondaryContainer = RetainColorLight.SecondaryContainer, - onSecondaryContainer = RetainColorLight.OnSecondaryContainer, - // secondaryFixed = RetainColorLight.SecondaryFixed, - // onSecondaryFixed = RetainColorLight.OnSecondaryFixed, - // secondaryFixedDim = RetainColorLight.SecondaryFixedDim, - // onSecondaryFixedVariant = RetainColorLight.OnSecondaryFixedVariant, - tertiary = RetainColorLight.Tertiary, - onTertiary = RetainColorLight.OnTertiary, - tertiaryContainer = RetainColorLight.TertiaryContainer, - onTertiaryContainer = RetainColorLight.OnTertiaryContainer, - // tertiaryFixed = RetainColorLight.TertiaryFixed, - // onTertiaryFixed = RetainColorLight.OnTertiaryFixed, - // tertiaryFixedDim = RetainColorLight.TertiaryFixedDim, - // onTertiaryFixedVariant = RetainColorLight.OnTertiaryFixedVariant, - error = RetainColorLight.Error, - onError = RetainColorLight.OnError, - errorContainer = RetainColorLight.ErrorContainer, - onErrorContainer = RetainColorLight.OnErrorContainer, - outline = RetainColorLight.Outline, - background = RetainColorLight.Background, - onBackground = RetainColorLight.OnBackground, - surface = RetainColorLight.Surface, - onSurface = RetainColorLight.OnSurface, - surfaceVariant = RetainColorLight.SurfaceVariant, - onSurfaceVariant = RetainColorLight.OnSurfaceVariant, - inverseSurface = RetainColorLight.InverseSurface, - inverseOnSurface = RetainColorLight.InverseOnSurface, - inversePrimary = RetainColorLight.InversePrimary, - surfaceTint = RetainColorLight.SurfaceTint, - outlineVariant = RetainColorLight.OutlineVariant, - scrim = RetainColorLight.Scrim, - // surfaceContainerHighest = RetainColorLight.SurfaceContainerHighest, - // surfaceContainerHigh = RetainColorLight.SurfaceContainerHigh, - // surfaceContainer = RetainColorLight.SurfaceContainer, - // surfaceContainerLow = RetainColorLight.SurfaceContainerLow, - // surfaceContainerLowest = RetainColorLight.SurfaceContainerLowest, - // surfaceBright = RetainColorLight.SurfaceBright, - // surfaceDim = RetainColorLight.SurfaceDim, -) - -private val DarkColors = darkColorScheme( - primary = RetainColorDark.Primary, - onPrimary = RetainColorDark.OnPrimary, - primaryContainer = RetainColorDark.PrimaryContainer, - onPrimaryContainer = RetainColorDark.OnPrimaryContainer, - // primaryFixed = RetainColorDark.PrimaryFixed, - // onPrimaryFixed = RetainColorDark.OnPrimaryFixed, - // primaryFixedDim = RetainColorDark.PrimaryFixedDim, - // onPrimaryFixedVariant = RetainColorDark.OnPrimaryFixedVariant, - secondary = RetainColorDark.Secondary, - onSecondary = RetainColorDark.OnSecondary, - secondaryContainer = RetainColorDark.SecondaryContainer, - onSecondaryContainer = RetainColorDark.OnSecondaryContainer, - // secondaryFixed = RetainColorDark.SecondaryFixed, - // onSecondaryFixed = RetainColorDark.OnSecondaryFixed, - // secondaryFixedDim = RetainColorDark.SecondaryFixedDim, - // onSecondaryFixedVariant = RetainColorDark.OnSecondaryFixedVariant, - tertiary = RetainColorDark.Tertiary, - onTertiary = RetainColorDark.OnTertiary, - tertiaryContainer = RetainColorDark.TertiaryContainer, - onTertiaryContainer = RetainColorDark.OnTertiaryContainer, - // tertiaryFixed = RetainColorDark.TertiaryFixed, - // onTertiaryFixed = RetainColorDark.OnTertiaryFixed, - // tertiaryFixedDim = RetainColorDark.TertiaryFixedDim, - // onTertiaryFixedVariant = RetainColorDark.OnTertiaryFixedVariant, - error = RetainColorDark.Error, - onError = RetainColorDark.OnError, - errorContainer = RetainColorDark.ErrorContainer, - onErrorContainer = RetainColorDark.OnErrorContainer, - outline = RetainColorDark.Outline, - background = RetainColorDark.Background, - onBackground = RetainColorDark.OnBackground, - surface = RetainColorDark.Surface, - onSurface = RetainColorDark.OnSurface, - surfaceVariant = RetainColorDark.SurfaceVariant, - onSurfaceVariant = RetainColorDark.OnSurfaceVariant, - inverseSurface = RetainColorDark.InverseSurface, - inverseOnSurface = RetainColorDark.InverseOnSurface, - inversePrimary = RetainColorDark.InversePrimary, - surfaceTint = RetainColorDark.SurfaceTint, - outlineVariant = RetainColorDark.OutlineVariant, - scrim = RetainColorDark.Scrim, - // surfaceContainerHighest = RetainColorDark.SurfaceContainerHighest, - // surfaceContainerHigh = RetainColorDark.SurfaceContainerHigh, - // surfaceContainer = RetainColorDark.SurfaceContainer, - // surfaceContainerLow = RetainColorDark.SurfaceContainerLow, - // surfaceContainerLowest = RetainColorDark.SurfaceContainerLowest, - // surfaceBright = RetainColorDark.SurfaceBright, - // surfaceDim = RetainColorDark.SurfaceDim, -) +import us.huseli.retaintheme.ui.theme.DarkColors +import us.huseli.retaintheme.ui.theme.LightColors +import us.huseli.retaintheme.ui.theme.Typography @Suppress("unused") @Composable @@ -152,20 +50,3 @@ fun RetainThemeOld( ) } -@Composable -fun RetainTheme( - useDarkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit -) { - val colors = if (!useDarkTheme) LightColors else DarkColors - val systemUiController = rememberSystemUiController() - - systemUiController.setStatusBarColor(colors.surface) - systemUiController.setNavigationBarColor(colors.background) - - MaterialTheme( - colorScheme = colors, - typography = Typography, - content = content - ) -} diff --git a/app/src/main/java/us/huseli/retain/ui/theme/Type.kt b/app/src/main/java/us/huseli/retain/ui/theme/Type.kt deleted file mode 100644 index b688331..0000000 --- a/app/src/main/java/us/huseli/retain/ui/theme/Type.kt +++ /dev/null @@ -1,34 +0,0 @@ -package us.huseli.retain.ui.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -// Set of Material typography styles to start with -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ), - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file diff --git a/app/src/main/java/us/huseli/retain/viewmodels/BaseEditNoteViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/BaseEditNoteViewModel.kt index eb35f50..a736a9c 100644 --- a/app/src/main/java/us/huseli/retain/viewmodels/BaseEditNoteViewModel.kt +++ b/app/src/main/java/us/huseli/retain/viewmodels/BaseEditNoteViewModel.kt @@ -29,10 +29,7 @@ data class ChecklistItemFlow( val checked: MutableStateFlow = MutableStateFlow(item.checked), val position: MutableStateFlow = MutableStateFlow(item.position), val textFieldValue: MutableStateFlow = MutableStateFlow( - TextFieldValue( - addNullChar(item.text), - TextRange(1) - ) + TextFieldValue(addNullChar(item.text), TextRange(1)) ) ) { override fun equals(other: Any?): Boolean { diff --git a/app/src/main/java/us/huseli/retain/viewmodels/DropboxViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/DropboxViewModel.kt new file mode 100644 index 0000000..8984bdf --- /dev/null +++ b/app/src/main/java/us/huseli/retain/viewmodels/DropboxViewModel.kt @@ -0,0 +1,36 @@ +package us.huseli.retain.viewmodels + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import us.huseli.retain.data.SyncBackendRepository +import us.huseli.retain.syncbackend.DropboxEngine +import us.huseli.retain.syncbackend.tasks.TestTaskResult +import javax.inject.Inject + +@HiltViewModel +class DropboxViewModel @Inject constructor( + private val engine: DropboxEngine, + private val repository: SyncBackendRepository, +) : ViewModel() { + private val _isWorking = MutableStateFlow(null) + + val accountEmail = engine.accountEmail + val isAuthenticated = engine.isAuthenticated + val isTesting = engine.isTesting + val isWorking = _isWorking.asStateFlow() + + fun authenticate() = engine.authenticate() + + fun revoke() = engine.revoke() + + fun test(onResult: (TestTaskResult) -> Unit) { + _isWorking.value = null + repository.needsTesting.value = false + engine.test { result -> + _isWorking.value = result.success + onResult(result) + } + } +} diff --git a/app/src/main/java/us/huseli/retain/viewmodels/EditChecklistNoteViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/EditChecklistNoteViewModel.kt index 79222e1..8b55b0b 100644 --- a/app/src/main/java/us/huseli/retain/viewmodels/EditChecklistNoteViewModel.kt +++ b/app/src/main/java/us/huseli/retain/viewmodels/EditChecklistNoteViewModel.kt @@ -97,6 +97,7 @@ class EditChecklistNoteViewModel @Inject constructor( item = previousItem, text = previousItem.textFieldValue.value.text + stripNullChar(item.textFieldValue.value.text), selection = TextRange(previousItem.textFieldValue.value.text.length), + composition = previousItem.textFieldValue.value.composition, ) } _focusedItemId.value = previousItem.id @@ -113,9 +114,15 @@ class EditChecklistNoteViewModel @Inject constructor( val head = textFieldValue.text.substring(0, textFieldValue.selection.start) val tail = textFieldValue.text.substring(textFieldValue.selection.start) - log("onNextItem: item=$item, head=$head, tail=$tail") + log("onNextItem: item=$item, head=$head, tail=$tail, index=$index") + if (tail.isNotEmpty()) updateItemTextFieldValue( + item = item, + text = head, + selection = item.textFieldValue.value.selection, + composition = null, + // composition = item.textFieldValue.value.composition, + ) insertItem(tail, item.checked.value, index + 1) - if (tail.isNotEmpty()) updateItemTextFieldValue(item = item, text = head) } fun onTextFieldValueChange(item: ChecklistItemFlow, textFieldValue: TextFieldValue) { @@ -126,19 +133,23 @@ class EditChecklistNoteViewModel @Inject constructor( * one above. If this is the first row: just re-insert the null * character and move the selection start to after it. */ - val index = _checklistItems.value.indexOfFirst { it.id == item.id } - - if (index > -1) { - if (textFieldValue.text.isEmpty() || textFieldValue.text[0] != INVISIBLE_CHAR) { + if (item.id == _focusedItemId.value && textFieldValue != item.textFieldValue.value) { + log("onTextFieldValueChange: textFieldValue=$textFieldValue, item.textFieldValue.value=${item.textFieldValue.value}") + if ( + textFieldValue.text.getOrNull(0) != INVISIBLE_CHAR && + addNullChar(textFieldValue.text) == item.textFieldValue.value.text + ) { + val index = _checklistItems.value + .filter { it.checked.value == item.checked.value } + .indexOfFirst { it.id == item.id } if (index > 0) mergeItemWithPrevious(item) - else if (textFieldValue.text.isEmpty()) deleteItem(item, true) - } else { - updateItemTextFieldValue( - item = item, - text = textFieldValue.text, - selection = textFieldValue.selection, - ) - } + else if (index == 0 && textFieldValue.text.isEmpty()) deleteItem(item, true) + } else updateItemTextFieldValue( + item = item, + text = textFieldValue.text, + selection = textFieldValue.selection, + composition = textFieldValue.composition, + ) } } @@ -195,13 +206,17 @@ class EditChecklistNoteViewModel @Inject constructor( private fun updateItemTextFieldValue( item: ChecklistItemFlow, - text: String? = null, - selection: TextRange? = null, + text: String, + selection: TextRange, + composition: TextRange?, ) { + log("updateItemTextFieldValue before: ${item.textFieldValue.value}, text = $text, selection = $selection, composition=$composition") item.textFieldValue.value = item.textFieldValue.value.copy( - text = text?.let { addNullChar(it) } ?: item.textFieldValue.value.text, - selection = selection?.let { adjustSelection(it) } ?: item.textFieldValue.value.selection, + text = addNullChar(text), + selection = adjustSelection(selection), + composition = composition, ) + log("updateItemTextFieldValue after: ${item.textFieldValue.value}, text = $text, selection = $selection, composition=$composition") _dirtyChecklistItems.removeIf { it.id == item.id } if (_originalChecklistItems.none { it.id == item.id && it.text == stripNullChar(item.textFieldValue.value.text) }) _dirtyChecklistItems.add(item) diff --git a/app/src/main/java/us/huseli/retain/viewmodels/NextCloudViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/NextCloudViewModel.kt new file mode 100644 index 0000000..a094c3b --- /dev/null +++ b/app/src/main/java/us/huseli/retain/viewmodels/NextCloudViewModel.kt @@ -0,0 +1,127 @@ +package us.huseli.retain.viewmodels + +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import us.huseli.retain.Constants.DEFAULT_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.data.SyncBackendRepository +import us.huseli.retain.syncbackend.NextCloudEngine +import us.huseli.retain.syncbackend.tasks.TaskResult +import us.huseli.retain.syncbackend.tasks.TestTaskResult +import javax.inject.Inject + +@HiltViewModel +class NextCloudViewModel @Inject constructor( + @ApplicationContext context: Context, + private val repository: SyncBackendRepository, + private val engine: NextCloudEngine, +) : ViewModel(), SharedPreferences.OnSharedPreferenceChangeListener { + private val preferences = PreferenceManager.getDefaultSharedPreferences(context) + private var dataChanged = false + private val _baseDir = MutableStateFlow( + preferences.getString(PREF_NEXTCLOUD_BASE_DIR, DEFAULT_NEXTCLOUD_BASE_DIR) ?: DEFAULT_NEXTCLOUD_BASE_DIR + ) + private val _isAuthError = MutableStateFlow(false) + private val _isUrlError = MutableStateFlow(false) + private val _isWorking = MutableStateFlow(null) + private val _password = MutableStateFlow(preferences.getString(PREF_NEXTCLOUD_PASSWORD, "") ?: "") + private val _uri = MutableStateFlow(preferences.getString(PREF_NEXTCLOUD_URI, "") ?: "") + private val _username = MutableStateFlow(preferences.getString(PREF_NEXTCLOUD_USERNAME, "") ?: "") + + val baseDir = _baseDir.asStateFlow() + val isAuthError = _isAuthError.asStateFlow() + val isTesting = engine.isTesting + val isUrlError = _isUrlError.asStateFlow() + val isWorking = _isWorking.asStateFlow() + val password = _password.asStateFlow() + val uri = _uri.asStateFlow() + val username = _username.asStateFlow() + + init { + preferences.registerOnSharedPreferenceChangeListener(this) + repository.addOnSaveListener { save() } + } + + private fun resetStatus() { + _isAuthError.value = false + _isUrlError.value = false + _isWorking.value = null + } + + private fun save() { + preferences.edit() + .putString(PREF_NEXTCLOUD_BASE_DIR, _baseDir.value) + .putString(PREF_NEXTCLOUD_PASSWORD, _password.value) + .putString(PREF_NEXTCLOUD_URI, _uri.value) + .putString(PREF_NEXTCLOUD_USERNAME, _username.value) + .apply() + if (dataChanged) { + repository.needsTesting.value = true + dataChanged = false + } + } + + fun test(onResult: (TestTaskResult) -> Unit) = viewModelScope.launch { + repository.needsTesting.value = false + engine.test( + uri = Uri.parse(_uri.value), + username = _username.value, + password = _password.value, + baseDir = _baseDir.value, + ) { result -> + _isWorking.value = result.success + _isUrlError.value = + result.status == TaskResult.Status.UNKNOWN_HOST || result.status == TaskResult.Status.CONNECT_ERROR + _isAuthError.value = result.status == TaskResult.Status.AUTH_ERROR + onResult(result) + } + } + + fun updateField(field: String, value: Any) { + when (field) { + PREF_NEXTCLOUD_URI -> { + if (value != _uri.value) { + _uri.value = value as String + dataChanged = true + resetStatus() + } + } + PREF_NEXTCLOUD_USERNAME -> { + if (value != _username.value) { + _username.value = value as String + dataChanged = true + resetStatus() + } + } + PREF_NEXTCLOUD_PASSWORD -> { + if (value != _password.value) { + _password.value = value as String + dataChanged = true + resetStatus() + } + } + } + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + PREF_NEXTCLOUD_BASE_DIR -> _baseDir.value = + preferences.getString(key, DEFAULT_NEXTCLOUD_BASE_DIR) ?: DEFAULT_NEXTCLOUD_BASE_DIR + PREF_NEXTCLOUD_PASSWORD -> _password.value = preferences.getString(key, "") ?: "" + PREF_NEXTCLOUD_URI -> _uri.value = preferences.getString(key, "") ?: "" + PREF_NEXTCLOUD_USERNAME -> _username.value = preferences.getString(key, "") ?: "" + } + } +} 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 776d68e..953527f 100644 --- a/app/src/main/java/us/huseli/retain/viewmodels/NoteViewModel.kt +++ b/app/src/main/java/us/huseli/retain/viewmodels/NoteViewModel.kt @@ -41,25 +41,29 @@ class NoteViewModel @Inject constructor( private val _showArchive = MutableStateFlow(false) val syncBackend = syncBackendRepository.syncBackend - val isSyncBackendRefreshing = syncBackendRepository.hasActiveTasks + val isSyncBackendSyncing = syncBackendRepository.isSyncing val showArchive = _showArchive.asStateFlow() val trashedNoteCount = _trashedNotes.map { it.size } val isSelectEnabled = _selectedNoteIds.map { it.isNotEmpty() } val selectedNoteIds = _selectedNoteIds.asStateFlow() val notes = _notes.asStateFlow() val images: Flow> = repository.images - val checklistData = repository.checklistItems.map { items -> - items.groupBy { it.noteId }.map { (noteId, noteItems) -> - val shownItems = noteItems.subList(0, min(noteItems.size, 5)) - val hiddenItems = noteItems.minus(shownItems.toSet()) - - NoteCardChecklistData( - noteId = noteId, - shownChecklistItems = shownItems, - hiddenChecklistItemCount = hiddenItems.size, - hiddenChecklistItemAllChecked = hiddenItems.all { it.checked }, - ) - } + val checklistData = repository.checklistItemsWithNote.map { items -> + items + .groupBy { it.note } + .map { (note, itemsWithNote) -> note to itemsWithNote.map { it.checklistItem } } + .map { (note, items) -> + val filteredItems = if (note.showChecked) items else items.filter { !it.checked } + val shownItems = filteredItems.subList(0, min(filteredItems.size, 5)) + val hiddenItems = items.minus(shownItems.toSet()) + + NoteCardChecklistData( + noteId = note.id, + shownChecklistItems = shownItems, + hiddenChecklistItemCount = hiddenItems.size, + hiddenChecklistItemAllChecked = hiddenItems.all { it.checked }, + ) + } } init { @@ -183,6 +187,9 @@ class NoteViewModel @Inject constructor( } fun uploadNotes() = viewModelScope.launch { - syncBackendRepository.uploadNotes() + syncBackendRepository.uploadNotes { result -> + if (!result.success) + showError("Failed to upload note(s) to ${syncBackend.value.displayName}: ${result.message}") + } } } diff --git a/app/src/main/java/us/huseli/retain/viewmodels/SFTPViewModel.kt b/app/src/main/java/us/huseli/retain/viewmodels/SFTPViewModel.kt new file mode 100644 index 0000000..409f844 --- /dev/null +++ b/app/src/main/java/us/huseli/retain/viewmodels/SFTPViewModel.kt @@ -0,0 +1,140 @@ +package us.huseli.retain.viewmodels + +import android.content.Context +import android.content.SharedPreferences +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import us.huseli.retain.Constants.DEFAULT_SFTP_BASE_DIR +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.data.SyncBackendRepository +import us.huseli.retain.syncbackend.SFTPEngine +import us.huseli.retain.syncbackend.tasks.TestTaskResult +import javax.inject.Inject + +@HiltViewModel +class SFTPViewModel @Inject constructor( + @ApplicationContext context: Context, + private val engine: SFTPEngine, + private val repository: SyncBackendRepository, +) : ViewModel(), SharedPreferences.OnSharedPreferenceChangeListener { + private val preferences = PreferenceManager.getDefaultSharedPreferences(context) + private var dataChanged = false + private val _baseDir = + MutableStateFlow(preferences.getString(PREF_SFTP_BASE_DIR, DEFAULT_SFTP_BASE_DIR) ?: DEFAULT_SFTP_BASE_DIR) + private val _hostname = MutableStateFlow(preferences.getString(PREF_SFTP_HOSTNAME, "") ?: "") + private val _isWorking = MutableStateFlow(null) + 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, "") ?: "") + + val baseDir = _baseDir.asStateFlow() + val hostname = _hostname.asStateFlow() + val isTesting = engine.isTesting + val isWorking = _isWorking.asStateFlow() + val password = _password.asStateFlow() + val port = _port.asStateFlow() + val promptYesNo = engine.promptYesNo + val username = _username.asStateFlow() + + init { + preferences.registerOnSharedPreferenceChangeListener(this) + repository.addOnSaveListener { save() } + } + + fun approveKey() = engine.approveKey() + + fun denyKey() = engine.denyKey() + + private fun save() { + preferences.edit() + .putInt(PREF_SFTP_PORT, _port.value) + .putString(PREF_SFTP_BASE_DIR, _baseDir.value) + .putString(PREF_SFTP_HOSTNAME, _hostname.value) + .putString(PREF_SFTP_PASSWORD, _password.value) + .putString(PREF_SFTP_USERNAME, _username.value) + .apply() + if (dataChanged) { + repository.needsTesting.value = true + dataChanged = false + } + } + + fun test(onResult: (TestTaskResult) -> Unit) = viewModelScope.launch(Dispatchers.IO) { + repository.needsTesting.value = false + engine.test( + hostname = _hostname.value, + username = _username.value, + password = _password.value, + baseDir = _baseDir.value, + ) { result -> + _isWorking.value = result.success + onResult(result) + } + } + + fun updateField(field: String, value: Any) { + when (field) { + PREF_SFTP_BASE_DIR -> { + if (value != _baseDir.value) { + _baseDir.value = value as String + dataChanged = true + resetStatus() + } + } + PREF_SFTP_HOSTNAME -> { + if (value != _hostname.value) { + _hostname.value = value as String + dataChanged = true + resetStatus() + } + } + PREF_SFTP_PASSWORD -> { + if (value != _password.value) { + _password.value = value as String + dataChanged = true + resetStatus() + } + } + PREF_SFTP_PORT -> { + if (value != _port.value) { + _port.value = value as Int + dataChanged = true + resetStatus() + } + } + PREF_SFTP_USERNAME -> { + if (value != _username.value) { + _username.value = value as String + dataChanged = true + resetStatus() + } + } + } + } + + private fun resetStatus() { + _isWorking.value = null + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + PREF_SFTP_BASE_DIR -> _baseDir.value = + preferences.getString(key, DEFAULT_SFTP_BASE_DIR) ?: DEFAULT_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, "") ?: "" + } + } +} 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 7b38e4b..63a0abf 100644 --- a/app/src/main/java/us/huseli/retain/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/us/huseli/retain/viewmodels/SettingsViewModel.kt @@ -13,7 +13,6 @@ 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 @@ -26,19 +25,8 @@ import kotlinx.coroutines.withContext import okhttp3.internal.toImmutableList import org.jsoup.Jsoup 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_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 @@ -53,10 +41,6 @@ import us.huseli.retain.data.entities.NoteCombo import us.huseli.retain.extractFileFromZip import us.huseli.retain.isImageFile 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 @@ -103,73 +87,48 @@ class SettingsViewModel @Inject constructor( @ApplicationContext context: Context, private val repository: NoteRepository, 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 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 minColumnWidth = MutableStateFlow(preferences.getInt(PREF_MIN_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH)) - 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 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 + private val _importActionCount = MutableStateFlow(null) + private val _importCurrentAction = MutableStateFlow("") + private val _importCurrentActionIndex = MutableStateFlow(0) + private val _keepImportIsActive = MutableStateFlow(false) + private val _minColumnWidth = MutableStateFlow(preferences.getInt(PREF_MIN_COLUMN_WIDTH, DEFAULT_MIN_COLUMN_WIDTH)) + private val _quickNoteImportIsActive = MutableStateFlow(false) + private val _syncBackend = MutableStateFlow(originalSyncBackend) + + val importActionCount = _importActionCount.asStateFlow() + val importCurrentAction = _importCurrentAction.asStateFlow() + val importCurrentActionIndex = _importCurrentActionIndex.asStateFlow() + val isSyncBackendEnabled = _syncBackend.map { it != null && it != SyncBackend.NONE } + val keepImportIsActive = _keepImportIsActive.asStateFlow() + val minColumnWidth = _minColumnWidth.asStateFlow() + val quickNoteImportIsActive = _quickNoteImportIsActive.asStateFlow() + val syncBackend = _syncBackend.asStateFlow() init { preferences.registerOnSharedPreferenceChangeListener(this) - jsch.setKnownHosts(knownHostsFile.absolutePath) } - fun approveSFTPKey() = sftpEngine.approveKey() - fun cancelImport() { importJob?.cancel() - quickNoteImportIsActive.value = false - keepImportIsActive.value = false + _quickNoteImportIsActive.value = false + _keepImportIsActive.value = false } - fun denySFTPKey() = sftpEngine.denyKey() - fun getSectionShown(key: String, default: Boolean): State { 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 { try { @@ -198,7 +157,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 { @@ -270,15 +229,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 { @@ -328,147 +287,36 @@ class SettingsViewModel @Inject constructor( } catch (e: Exception) { log("Error: $e", level = Log.ERROR, showInSnackbar = true) } finally { - quickNoteImportIsActive.value = false + _quickNoteImportIsActive.value = false } } } fun save() { + syncBackendRepository.save() preferences.edit() - .putString(PREF_NEXTCLOUD_URI, nextCloudUri.value) - .putString(PREF_NEXTCLOUD_USERNAME, nextCloudUsername.value) - .putString(PREF_NEXTCLOUD_PASSWORD, nextCloudPassword.value) - .putString(PREF_NEXTCLOUD_BASE_DIR, nextCloudBaseDir.value) - .putInt(PREF_MIN_COLUMN_WIDTH, minColumnWidth.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) + .putInt(PREF_MIN_COLUMN_WIDTH, _minColumnWidth.value) + .putString(PREF_SYNC_BACKEND, _syncBackend.value?.name) .apply() - if (syncBackendDataChanged) { - syncBackendRepository.needsTesting.value = true - syncBackendDataChanged = false - } - if (!listOf(SyncBackend.NONE, originalSyncBackend, null).contains(syncBackend.value)) { - syncBackendRepository.needsTesting.value = true - originalSyncBackend = syncBackend.value + if (!listOf(SyncBackend.NONE, originalSyncBackend, null).contains(_syncBackend.value)) { + 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: (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 -> - isNextCloudWorking.value = result.success - 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 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) - } - } - } - fun toggleSectionShown(key: String) { sectionsShown.getOrPut(key) { mutableStateOf(true) }.apply { value = !value } } fun updateField(field: String, value: Any) { when (field) { - PREF_NEXTCLOUD_URI -> { - if (value != nextCloudUri.value) { - nextCloudUri.value = value as String - syncBackendDataChanged = true - resetNextCloudStatus() - } - } - PREF_NEXTCLOUD_USERNAME -> { - if (value != nextCloudUsername.value) { - nextCloudUsername.value = value as String - syncBackendDataChanged = true - resetNextCloudStatus() - } - } - PREF_NEXTCLOUD_PASSWORD -> { - if (value != nextCloudPassword.value) { - nextCloudPassword.value = value as String - syncBackendDataChanged = true - resetNextCloudStatus() - } - } - PREF_MIN_COLUMN_WIDTH -> minColumnWidth.value = value as Int - 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_MIN_COLUMN_WIDTH -> _minColumnWidth.value = value as Int PREF_SYNC_BACKEND -> { - if (value != syncBackend.value) { - syncBackend.value = value as SyncBackend - preferences.edit().putString(PREF_SYNC_BACKEND, syncBackend.value?.name).apply() + if (value != _syncBackend.value) { + _syncBackend.value = value as SyncBackend + preferences.edit().putString(PREF_SYNC_BACKEND, _syncBackend.value?.name).apply() } } } @@ -578,35 +426,15 @@ class SettingsViewModel @Inject constructor( 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 + _importCurrentActionIndex.value++ + _importCurrentAction.value = action } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { when (key) { - PREF_MIN_COLUMN_WIDTH -> minColumnWidth.value = preferences.getInt(key, DEFAULT_MIN_COLUMN_WIDTH) - 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 = + PREF_MIN_COLUMN_WIDTH -> _minColumnWidth.value = preferences.getInt(key, DEFAULT_MIN_COLUMN_WIDTH) + 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 45ca637..b4e74c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,7 +24,8 @@ Test connection Testing … Nextcloud error - Unknown error + An error occurred. + The error was: Successfully connected to Nextcloud. The server reported an authorization error. Username and/or password is probably incorrect. No host with this name was found. @@ -65,7 +66,14 @@ Do not sync SFTP base directory Relative to the user\'s home directory. - backend + Sync with Dropbox + Not connected to Dropbox. + Connected to Dropbox account: %1$s. + Connect + Revoke + The Dropbox connection is working. + The Dropbox connection is not working somehow. + Dropbox error + 1 item + %1$d items diff --git a/screenshots/Screenshot_20230731_113418.png b/screenshots/Screenshot_20230731_113418.png new file mode 100644 index 0000000..a6bf9e2 Binary files /dev/null and b/screenshots/Screenshot_20230731_113418.png differ diff --git a/screenshots/Screenshot_20230731_113554.png b/screenshots/Screenshot_20230731_113554.png new file mode 100644 index 0000000..1f99f1d Binary files /dev/null and b/screenshots/Screenshot_20230731_113554.png differ diff --git a/screenshots/Screenshot_20230731_113655.png b/screenshots/Screenshot_20230731_113655.png new file mode 100644 index 0000000..9ab1244 Binary files /dev/null and b/screenshots/Screenshot_20230731_113655.png differ