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