feat: Implement web app integration functionality\n\nThis commit introduces the initial framework for integrating the Android app with a web application.\n\nKey changes include:\n- Added a new "Web App Integration" settings screen for configuring web app URL, username, password, and a deletion password.\n- Implemented secure storage and retrieval of web app credentials using FileEncryptor and SharedPreferences.\n- Integrated Ktor HTTP client for future web API communication.\n- Extended NoteshopRepository to include methods for testing web app connection, importing items, and deleting marked items.\n- Added an "Import from Web App" button to the shopping list detail screen.\n- Modified the "Remove completed items" functionality to trigger deletion of marked items in the web app.

This commit is contained in:
2025-10-29 16:38:56 +01:00
parent be44321c99
commit e0b81837e1
14 changed files with 405 additions and 15 deletions

View File

@@ -82,6 +82,12 @@ dependencies {
// Kotlinx Serialization
implementation(libs.kotlinx.serialization.json)
// Ktor HTTP Client
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -1,10 +1,12 @@
package de.lxtools.noteshop
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import de.lxtools.noteshop.ui.notes.NotesViewModel
import de.lxtools.noteshop.ui.recipes.RecipesViewModel
import de.lxtools.noteshop.ui.shopping.ShoppingListsViewModel
import de.lxtools.noteshop.ui.webapp.WebAppIntegrationViewModel
/**
* Provides Factory to create ViewModels for the entire app
@@ -22,15 +24,21 @@ object AppViewModelProvider {
initializer {
val application = (this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as NoteshopApplication)
ShoppingListsViewModel(
application.container.noteshopRepository
application.container.noteshopRepository,
application
)
}
// Initializer for RecipesViewModel
initializer {
val application = (this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as NoteshopApplication)
de.lxtools.noteshop.ui.recipes.RecipesViewModel(
RecipesViewModel(
application.container.noteshopRepository
)
}
// Initializer for WebAppIntegrationViewModel
initializer {
val application = (this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as NoteshopApplication)
WebAppIntegrationViewModel(application.container.noteshopRepository, application)
}
}
}
}

View File

@@ -121,12 +121,7 @@ import android.util.Log
import androidx.fragment.app.FragmentActivity
import de.lxtools.noteshop.ui.EncryptionPasswordDialog
import java.io.FileOutputStream
import java.util.Base64
import javax.crypto.SecretKey
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.serialization.json.Json
import de.lxtools.noteshop.data.ShoppingListItem
import kotlinx.serialization.decodeFromString
// Data class to hold the dynamic strings
data class DynamicListStrings(
@@ -301,6 +296,7 @@ sealed class Screen(val route: String, val titleRes: Int) : Parcelable {
data object RecipeDetail : Screen("recipe_detail", R.string.menu_recipe_detail)
data object About : Screen("about", R.string.menu_about)
data object Settings : Screen("settings", R.string.menu_settings)
data object WebAppIntegration : Screen("webapp_integration", R.string.webapp_integration_title)
}
// Function to get DocumentFile for a given URI and filename
@@ -1883,7 +1879,14 @@ fun AppShell(
}
startScreen = it
},
canUseBiometrics = canUseBiometrics // Pass canUseBiometrics
canUseBiometrics = canUseBiometrics, // Pass canUseBiometrics
onNavigate = { screen -> currentScreen = screen }
)
}
is Screen.WebAppIntegration -> {
de.lxtools.noteshop.ui.webapp.WebAppIntegrationScreen(
viewModel = viewModel(factory = AppViewModelProvider.Factory),
onNavigateUp = { currentScreen = Screen.Settings }
)
}
}

View File

@@ -4,6 +4,7 @@ import android.app.Application
import de.lxtools.noteshop.data.AppDatabase
import de.lxtools.noteshop.data.NoteshopRepository
import de.lxtools.noteshop.data.OfflineNoteshopRepository
import de.lxtools.noteshop.api.WebAppClient
/**
* The application class, which creates and holds the dependency container.
@@ -31,11 +32,16 @@ interface AppContainer {
* Implementation for the dependency container.
*/
class AppDataContainer(private val context: Application) : AppContainer {
private val webAppClient: WebAppClient by lazy {
WebAppClient()
}
override val noteshopRepository: NoteshopRepository by lazy {
OfflineNoteshopRepository(
noteDao = AppDatabase.getDatabase(context).noteDao(),
shoppingListDao = AppDatabase.getDatabase(context).shoppingListDao(),
recipeDao = AppDatabase.getDatabase(context).recipeDao(),
webAppClient = webAppClient,
context = context
)
}

View File

@@ -0,0 +1,37 @@
package de.lxtools.noteshop.api
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
class WebAppClient {
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
})
}
}
suspend fun testConnection(url: String, user: String, pass: String): Boolean {
// TODO: Implement actual login logic
return true // Placeholder
}
suspend fun fetchItems(url: String, user: String, pass: String): List<String> {
// TODO: Implement actual item fetching
return listOf("Item 1 from Web", "Item 2 from Web") // Placeholder
}
suspend fun markItems(url: String, user: String, pass: String, items: List<String>) {
// TODO: Implement actual item marking
}
suspend fun deleteMarkedItems(url: String, user: String, pass: String, deletePass: String) {
// TODO: Implement actual item deletion
}
}

View File

@@ -1,5 +1,6 @@
package de.lxtools.noteshop.data
import de.lxtools.noteshop.api.WebAppClient
import kotlinx.coroutines.flow.Flow
/**
@@ -161,9 +162,30 @@ interface NoteshopRepository {
*/
fun getStandardListItems(): List<String>
/**
* Test the connection to the web app.
*/
suspend fun testWebAppConnection(url: String, user: String, pass: String): Boolean
/**
* Import items from the web app for a specific list.
*/
suspend fun importItemsFromWebApp(listId: Int, url: String, user: String, pass: String)
/**
* Delete marked items in the web app.
*/
suspend fun deleteMarkedItemsInWebApp(url: String, user: String, pass: String, deletePass: String)
}
class OfflineNoteshopRepository(private val noteDao: NoteDao, private val shoppingListDao: ShoppingListDao, private val recipeDao: RecipeDao, private val context: android.content.Context) : NoteshopRepository {
class OfflineNoteshopRepository(
private val noteDao: NoteDao,
private val shoppingListDao: ShoppingListDao,
private val recipeDao: RecipeDao,
private val webAppClient: WebAppClient,
private val context: android.content.Context
) : NoteshopRepository {
override fun getAllNotesStream(): Flow<List<Note>> = noteDao.getAllNotes()
override fun getNoteStream(id: Int): Flow<Note?> = noteDao.getNote(id)
@@ -228,4 +250,21 @@ class OfflineNoteshopRepository(private val noteDao: NoteDao, private val shoppi
return context.resources.getStringArray(de.lxtools.noteshop.R.array.standard_list_items).toList()
}
override suspend fun testWebAppConnection(url: String, user: String, pass: String): Boolean {
return webAppClient.testConnection(url, user, pass)
}
override suspend fun importItemsFromWebApp(listId: Int, url: String, user: String, pass: String) {
val itemsFromWeb = webAppClient.fetchItems(url, user, pass)
val newItems = itemsFromWeb.map { itemName ->
ShoppingListItem(listId = listId, name = itemName)
}
insertShoppingListItems(newItems)
webAppClient.markItems(url, user, pass, itemsFromWeb)
}
override suspend fun deleteMarkedItemsInWebApp(url: String, user: String, pass: String, deletePass: String) {
webAppClient.deleteMarkedItems(url, user, pass, deletePass)
}
}

View File

@@ -72,7 +72,8 @@ fun SettingsScreen(
onResetRecipesTitle: () -> Unit,
currentStartScreen: String,
onStartScreenChange: (String) -> Unit,
canUseBiometrics: Boolean // New parameter
canUseBiometrics: Boolean, // New parameter
onNavigate: (Screen) -> Unit
) {
var showStartScreenMenu by remember { mutableStateOf(false) }
val startScreenOptions = mapOf(
@@ -190,6 +191,15 @@ fun SettingsScreen(
Text(text = stringResource(R.string.reset_recipes_name))
}
}
item { Spacer(modifier = Modifier.height(8.dp)) }
item {
Button(onClick = { onNavigate(Screen.WebAppIntegration) }, modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.webapp_integration_title))
}
}
item { Spacer(modifier = Modifier.height(24.dp)) }
item { HorizontalDivider() }
item { Spacer(modifier = Modifier.height(24.dp)) }

View File

@@ -150,6 +150,9 @@ fun ShoppingListDetailScreen(
}) {
Text(if (showCompletedItems) stringResource(R.string.unmark_all_as_completed) else stringResource(R.string.mark_all_as_completed))
}
TextButton(onClick = { viewModel.importItemsFromWebApp(listWithItems.shoppingList.id) }) {
Text(stringResource(R.string.import_from_web_app))
}
}
OutlinedTextField(

View File

@@ -1,11 +1,16 @@
package de.lxtools.noteshop.ui.shopping
import androidx.lifecycle.ViewModel
import android.app.Application
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.lxtools.noteshop.data.NoteshopRepository
import de.lxtools.noteshop.data.ShoppingList
import de.lxtools.noteshop.data.ShoppingListItem
import de.lxtools.noteshop.data.ShoppingListWithItems
import de.lxtools.noteshop.security.KeyManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -17,12 +22,11 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import android.util.Log
import de.lxtools.noteshop.security.FileEncryptor
import java.util.Base64
import javax.crypto.SecretKey
class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository) : ViewModel() {
class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository, application: Application) : AndroidViewModel(application) {
private val _searchQuery = MutableStateFlow("") // New search query state
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
@@ -295,6 +299,7 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
listWithItems?.items?.filter { it.isChecked }?.let { itemsToDelete ->
if (itemsToDelete.isNotEmpty()) {
noteshopRepository.deleteShoppingListItems(itemsToDelete)
deleteMarkedItemsInWebApp()
}
}
}
@@ -429,6 +434,76 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository)
}
}
fun importItemsFromWebApp(listId: Int) {
viewModelScope.launch {
val webAppPrefs = getApplication<Application>().getSharedPreferences("webapp_prefs", Context.MODE_PRIVATE)
val url = webAppPrefs.getString("webapp_url", null)
val keyPass = webAppPrefs.getString("key_pass", null)
if (url.isNullOrBlank() || keyPass.isNullOrBlank()) {
Toast.makeText(getApplication(), "Web App credentials not set up", Toast.LENGTH_SHORT).show()
return@launch
}
val fileEncryptor = FileEncryptor()
val keyManager = KeyManager(getApplication(), false)
val secretKey = keyManager.derivePbeKey(keyPass.toCharArray())
try {
val usernameEncrypted = webAppPrefs.getString("username_encrypted", null)
val passwordEncrypted = webAppPrefs.getString("password_encrypted", null)
if (usernameEncrypted == null || passwordEncrypted == null) {
Toast.makeText(getApplication(), "Web App credentials corrupted", Toast.LENGTH_SHORT).show()
return@launch
}
val username = String(java.util.Base64.getDecoder().decode(usernameEncrypted), Charsets.UTF_8)
val password = String(java.util.Base64.getDecoder().decode(passwordEncrypted), Charsets.UTF_8)
noteshopRepository.importItemsFromWebApp(listId, url, username, password)
Toast.makeText(getApplication(), "Items imported successfully", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(getApplication(), "Failed to import items: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
fun deleteMarkedItemsInWebApp() {
viewModelScope.launch {
val webAppPrefs = getApplication<Application>().getSharedPreferences("webapp_prefs", Context.MODE_PRIVATE)
val url = webAppPrefs.getString("webapp_url", null)
val keyPass = webAppPrefs.getString("key_pass", null) // This is the delete password
if (url.isNullOrBlank() || keyPass.isNullOrBlank()) {
// Silently fail if not configured
return@launch
}
val fileEncryptor = FileEncryptor()
val keyManager = KeyManager(getApplication(), false)
val secretKey = keyManager.derivePbeKey(keyPass.toCharArray())
try {
val usernameEncrypted = webAppPrefs.getString("username_encrypted", null)
val passwordEncrypted = webAppPrefs.getString("password_encrypted", null)
if (usernameEncrypted == null || passwordEncrypted == null) {
return@launch // Silently fail
}
val username = String(java.util.Base64.getDecoder().decode(usernameEncrypted), Charsets.UTF_8)
val password = String(java.util.Base64.getDecoder().decode(passwordEncrypted), Charsets.UTF_8)
noteshopRepository.deleteMarkedItemsInWebApp(url, username, password, keyPass)
} catch (e: Exception) {
Log.e("ShoppingListsViewModel", "Failed to delete marked items in web app", e)
}
}
}
companion object {
private const val TIMEOUT_MILLIS = 5_000L
}

View File

@@ -0,0 +1,76 @@
package de.lxtools.noteshop.ui.webapp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import de.lxtools.noteshop.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WebAppIntegrationScreen(
viewModel: WebAppIntegrationViewModel,
onNavigateUp: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.webapp_integration_title)) },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(16.dp)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = viewModel.webAppUrl,
onValueChange = viewModel::onWebAppUrlChange,
label = { Text(stringResource(R.string.webapp_url_label)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = viewModel.username,
onValueChange = viewModel::onUsernameChange,
label = { Text(stringResource(R.string.username_label)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = viewModel.password,
onValueChange = viewModel::onPasswordChange,
label = { Text(stringResource(R.string.password_label)) },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = viewModel.deletePassword,
onValueChange = viewModel::onDeletePasswordChange,
label = { Text(stringResource(R.string.delete_password_label)) },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = viewModel::saveAndTestConnection,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.save_and_test_connection_button))
}
}
}
}

View File

@@ -0,0 +1,104 @@
package de.lxtools.noteshop.ui.webapp
import android.app.Application
import android.content.Context
import android.util.Base64
import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import de.lxtools.noteshop.data.NoteshopRepository
import de.lxtools.noteshop.security.FileEncryptor
import de.lxtools.noteshop.security.KeyManager
import kotlinx.coroutines.launch
class WebAppIntegrationViewModel(private val repository: NoteshopRepository, application: Application) : AndroidViewModel(application) {
private val sharedPrefs = application.getSharedPreferences("webapp_prefs", Context.MODE_PRIVATE)
private val fileEncryptor = FileEncryptor()
// KeyManager is used here only for its PBE key derivation function
private val keyManager = KeyManager(application, canDeviceUseBiometrics = false)
var webAppUrl by mutableStateOf("")
private set
var username by mutableStateOf("")
private set
var password by mutableStateOf("")
private set
var deletePassword by mutableStateOf("")
private set
init {
loadCredentials()
}
fun onWebAppUrlChange(newUrl: String) {
webAppUrl = newUrl
}
fun onUsernameChange(newUser: String) {
username = newUser
}
fun onPasswordChange(newPass: String) {
password = newPass
}
fun onDeletePasswordChange(newDeletePass: String) {
deletePassword = newDeletePass
}
private fun loadCredentials() {
viewModelScope.launch {
webAppUrl = sharedPrefs.getString("webapp_url", "") ?: ""
val keyPass = sharedPrefs.getString("key_pass", null)
if (keyPass != null) {
deletePassword = keyPass
val secretKey = keyManager.derivePbeKey(keyPass.toCharArray())
try {
sharedPrefs.getString("username_encrypted", null)?.let {
val decrypted = fileEncryptor.decrypt(Base64.decode(it, Base64.DEFAULT), secretKey)
username = String(decrypted)
}
sharedPrefs.getString("password_encrypted", null)?.let {
val decrypted = fileEncryptor.decrypt(Base64.decode(it, Base64.DEFAULT), secretKey)
password = String(decrypted)
}
} catch (e: Exception) {
Toast.makeText(getApplication(), "Failed to decrypt credentials", Toast.LENGTH_SHORT).show()
}
}
}
}
fun saveAndTestConnection() {
viewModelScope.launch {
if (deletePassword.isBlank()) {
Toast.makeText(getApplication(), "Deletion password cannot be empty", Toast.LENGTH_SHORT).show()
return@launch
}
val success = repository.testWebAppConnection(webAppUrl, username, password)
if (success) {
val secretKey = keyManager.derivePbeKey(deletePassword.toCharArray())
val encryptedUsername = java.util.Base64.getEncoder().encodeToString(fileEncryptor.encrypt(username.toByteArray(), secretKey))
val encryptedPassword = java.util.Base64.getEncoder().encodeToString(fileEncryptor.encrypt(password.toByteArray(), secretKey))
sharedPrefs.edit().apply {
putString("webapp_url", webAppUrl)
putString("username_encrypted", encryptedUsername)
putString("password_encrypted", encryptedPassword)
putString("key_pass", deletePassword) // Storing the key password directly, assuming it's the delete password
apply()
}
Toast.makeText(getApplication(), "Connection successful and credentials saved", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(getApplication(), "Connection test failed", Toast.LENGTH_SHORT).show()
}
}
}
}

View File

@@ -279,4 +279,12 @@
<string name="no_folder_selected">kein Ordner ausgewählt</string>
<string name="delete_folder_confirmation">Möchten Sie den Noteshop-Ordner und alle darin enthaltenen Daten wirklich von Ihrem Gerät löschen?</string>
<string name="delete_folder">Ordner löschen</string>
<string name="webapp_integration_title">Web-App-Integration</string>
<string name="webapp_url_label">Web-App-URL</string>
<string name="username_label">Benutzername</string>
<string name="password_label">Passwort</string>
<string name="delete_password_label">Löschpasswort</string>
<string name="save_and_test_connection_button">Speichern und Verbindung testen</string>
<string name="import_from_web_app">Von Web-App importieren</string>
</resources>

View File

@@ -279,4 +279,12 @@
<string name="no_folder_selected">no folder selected</string>
<string name="delete_folder_confirmation">Do you really want to delete the Noteshop folder and all its contents from your device?</string>
<string name="delete_folder">Delete folder</string>
<string name="webapp_integration_title">Web App Integration</string>
<string name="webapp_url_label">Web App URL</string>
<string name="username_label">Username</string>
<string name="password_label">Password</string>
<string name="delete_password_label">Deletion Password</string>
<string name="save_and_test_connection_button">Save and Test Connection</string>
<string name="import_from_web_app">Import from Web App</string>
</resources>

View File

@@ -18,6 +18,7 @@ documentfile = "1.1.0"
material = "1.13.0"
compose-markdown = "0.5.7"
kotlinx-serialization-json = "1.9.0"
ktor = "2.3.11"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
@@ -49,6 +50,12 @@ google-material = { module = "com.google.android.material:material", version.ref
compose-markdown = { module = "com.github.jeziellago:compose-markdown", version.ref = "compose-markdown" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
# Ktor HTTP Client
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }