4 Commits
Author SHA1 Message Date
sora 691bc82d52 Bill Sync 2026-09-06 02:57:01 +02:00
sora 8564f64576 Project Cleanup/Refactor 2026-09-06 02:56:32 +02:00
sora c6be8eda6b Manage Currencies 2026-09-06 02:56:02 +02:00
sora 7904aabbc3 Accessibility 2026-09-06 02:55:11 +02:00
27 changed files with 551 additions and 278 deletions
+22
View File
@@ -17,6 +17,8 @@ android {
targetSdk = 37
versionName project.hasProperty('VERSION_NAME') ? project.property('VERSION_NAME') : "1.4"
versionCode project.hasProperty('VERSION_CODE') ? project.property('VERSION_CODE').toInteger() : 1
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
@@ -74,6 +76,18 @@ android {
testOptions {
unitTests {
includeAndroidResources = true
all {
maxParallelForks = 2
forkEvery = 50
maxHeapSize = "1024m"
// Robolectric loads classes through its own sandbox classloader, which leaves them
// without a code-source location. Without this JaCoCo skips them entirely and the
// report only sees the handful of plain JVM classes.
jacoco {
includeNoLocationClasses = true
excludes = ["jdk.internal.*"]
}
}
}
}
@@ -136,6 +150,14 @@ dependencies {
testImplementation 'org.robolectric:robolectric:4.16.1'
testImplementation 'androidx.test:core:1.7.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0'
testImplementation 'androidx.compose.ui:ui-test-junit4'
androidTestImplementation platform('androidx.compose:compose-bom:2026.08.00')
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
androidTestImplementation 'io.mockk:mockk-android:1.14.11'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
}
tasks.register('jacocoTestReport', JacocoReport) {
@@ -48,7 +48,7 @@ fun AboutScreen(
title = { Text(stringResource(R.string.title_about)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
backgroundColor = MaterialTheme.colors.primary,
@@ -48,7 +48,7 @@ import java.util.Locale
class AccountActivity : AppCompatActivity() {
private val viewModel: AccountViewModel by viewModels()
internal val viewModel: AccountViewModel by viewModels()
companion object {
private val TAG = AccountActivity::class.java.simpleName
@@ -34,7 +34,7 @@ import java.util.Calendar
class EditBillActivity : AppCompatActivity() {
private val viewModel: EditBillViewModel by viewModels()
internal val viewModel: EditBillViewModel by viewModels()
private lateinit var db: CowspentSQLiteOpenHelper
private lateinit var bill: DBBill
private var projectType: ProjectType = ProjectType.LOCAL
@@ -331,7 +331,7 @@ class EditBillActivity : AppCompatActivity() {
bill.amount == viewModel.getFinalAmount() &&
bill.payerId == viewModel.payerId &&
bill.comment == viewModel.getFinalComment() &&
bill.repeat == viewModel.repeat &&
(bill.repeat ?: DBBill.NON_REPEATED) == viewModel.repeat &&
bill.categoryId == viewModel.categoryId &&
bill.paymentModeId == viewModel.paymentModeId &&
!owersChanged)
@@ -17,7 +17,7 @@ import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.ProjectType
class LabelBillsActivity : AppCompatActivity() {
private val viewModel: LabelBillsViewModel by viewModels()
internal val viewModel: LabelBillsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
@@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -223,7 +224,10 @@ fun BillSummaryCard(bill: DBBill, members: List<DBMember>, remainingCount: Int)
fun CategoryButton(icon: String, name: String, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
modifier = Modifier.fillMaxWidth().height(60.dp),
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.testTag("CategoryButton_$name"),
contentPadding = PaddingValues(2.dp)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
@@ -7,14 +7,8 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.showToast
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCurrency
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
@@ -23,9 +17,8 @@ import net.helcel.cowspent.util.ICallback
class ManageCurrenciesActivity : AppCompatActivity() {
private val viewModel: ManageCurrenciesViewModel by viewModels()
internal val viewModel: ManageCurrenciesViewModel by viewModels()
private var db: CowspentSQLiteOpenHelper? = null
private var selectedProjectID: Long = -1
private val editMainCurrencyCallBack: ICallback = object : ICallback {
override fun onFinish() {}
@@ -47,131 +40,45 @@ class ManageCurrenciesActivity : AppCompatActivity() {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
intent.extras?.let {
selectedProjectID = it.getLong(EXTRA_PROJECT_ID)
}
val selectedProjectID = intent.getLongExtra(EXTRA_PROJECT_ID, -1L)
if (selectedProjectID == -1L) {
Log.e(TAG, "Missing project id")
finish()
return
}
viewModel.projectId = selectedProjectID
viewModel.loadCurrencies()
db = CowspentSQLiteOpenHelper.getInstance(this)
lifecycleScope.launch {
val project = withContext(Dispatchers.IO) { db!!.getProject(selectedProjectID) }
viewModel.mainCurrencyName = project?.currencyName?.let { if (it == "null") "" else it } ?: ""
updateCurrenciesList()
setContent {
ThemeUtils.CowspentTheme {
ManageCurrenciesScreen(
viewModel = viewModel,
onBack = { finish() },
onSaveMain = { saveMainCurrency() },
onAdd = { addOrUpdateCurrency() },
onDelete = { deleteCurrency(it) },
onEdit = { startEditing(it) },
onCancelEdit = { cancelEditing() }
)
}
setContent {
ThemeUtils.CowspentTheme {
ManageCurrenciesScreen(
viewModel = viewModel,
onBack = { finish() },
onSaveMain = { saveMainCurrency() },
onAdd = { viewModel.addCurrency() },
onDelete = { viewModel.deleteCurrency(it.id) },
onEdit = { viewModel.startEditing(it) },
onCancelEdit = { viewModel.cancelEditing() }
)
}
}
}
private fun saveMainCurrency() {
val newMainCurrencyName = viewModel.mainCurrencyName
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db!!.updateProject(
projId = selectedProjectID,
newCurrencyName = newMainCurrencyName
)
val project = db!!.getProject(selectedProjectID)
if (project != null) {
db!!.syncIfRemote(project)
if (project.type == ProjectType.COSPEND) {
withContext(Dispatchers.Main) {
if (!db!!.cowspentServerSyncHelper
.editRemoteProject(
projId = selectedProjectID,
newName = project.name,
newMainCurrencyName = newMainCurrencyName,
callback = editMainCurrencyCallBack
)
) {
showToast(this@ManageCurrenciesActivity, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
}
}
} else {
withContext(Dispatchers.Main) {
showToast(this@ManageCurrenciesActivity, getString(R.string.currency_saved_success), Toast.LENGTH_LONG)
}
}
val project = db?.getProject(viewModel.projectId)
if (project != null) {
if (project.type == ProjectType.COSPEND) {
if (!db!!.cowspentServerSyncHelper.isSyncPossible) {
showToast(this, getString(R.string.remote_project_operation_no_network), Toast.LENGTH_LONG)
}
} else {
showToast(this, getString(R.string.currency_saved_success), Toast.LENGTH_LONG)
}
}
}
private fun addOrUpdateCurrency() {
val uiRate = try { viewModel.newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 }
val exchangeRate = if (uiRate != 0.0) 1.0 / uiRate else 0.0
val currencyName = viewModel.newCurrencyName
val editingId = viewModel.editingCurrencyId
lifecycleScope.launch {
withContext(Dispatchers.IO) {
if (editingId != null) {
db!!.updateCurrency(editingId, currencyName, exchangeRate)
val currency = db!!.getCurrency(editingId)
if (currency != null) {
db!!.setCurrencyStateSync(editingId, DBBill.STATE_EDITED)
}
} else {
val newCurrency = DBCurrency(
0, 0, selectedProjectID,
currencyName, exchangeRate, DBBill.STATE_ADDED
)
db!!.addCurrencyAndSync(newCurrency)
}
}
cancelEditing()
updateCurrenciesList()
}
}
private fun startEditing(currency: DBCurrency) {
viewModel.editingCurrencyId = currency.id
viewModel.newCurrencyName = currency.name ?: ""
val uiRate = if (currency.exchangeRate != 0.0) 1.0 / currency.exchangeRate else 0.0
viewModel.newCurrencyRate = uiRate.toString()
}
private fun cancelEditing() {
viewModel.editingCurrencyId = null
viewModel.newCurrencyName = ""
viewModel.newCurrencyRate = ""
}
private fun deleteCurrency(currency: DBCurrency) {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
db!!.setCurrencyStateSync(currency.id, DBBill.STATE_DELETED)
}
updateCurrenciesList()
}
}
private suspend fun updateCurrenciesList() {
val currenciesDB = withContext(Dispatchers.IO) {
val list = db!!.getCurrenciesOfProjectWithState(selectedProjectID, DBBill.STATE_ADDED).toMutableList()
list.addAll(db!!.getCurrenciesOfProjectWithState(selectedProjectID, DBBill.STATE_EDITED))
list.addAll(db!!.getCurrenciesOfProjectWithState(selectedProjectID, DBBill.STATE_OK))
list
}
withContext(Dispatchers.Main) {
viewModel.currencies = currenciesDB
}
viewModel.saveMainCurrency(editMainCurrencyCallBack)
}
override fun onSupportNavigateUp(): Boolean {
@@ -1,6 +1,7 @@
package net.helcel.cowspent.android.currencies
import android.annotation.SuppressLint
import android.app.Application
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -41,6 +42,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@@ -216,7 +219,7 @@ fun ManageCurrenciesScreen(
) {
Icon(
imageVector = if (isEditing) Icons.Default.Done else Icons.Default.Add,
contentDescription = null,
contentDescription = if (isEditing) "Done" else "Add",
tint = if (viewModel.isAddEnabled()) {
if (isEditing) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
} else MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
@@ -274,7 +277,9 @@ fun CurrencyRow(
shape = RoundedCornerShape(12.dp),
elevation = if (isEditing) 4.dp else 1.dp,
border = if (isEditing) BorderStroke(1.dp, MaterialTheme.colors.secondary.copy(alpha = 0.5f)) else null,
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.testTag("CurrencyRow_${currency.name}")
) {
Row(
modifier = Modifier
@@ -320,7 +325,7 @@ fun CurrencyRow(
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
contentDescription = "Delete",
tint = MaterialTheme.colors.error.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp)
)
@@ -347,9 +352,10 @@ fun CurrencyRowPreview() {
@Preview(showBackground = true)
@Composable
fun ManageCurrenciesScreenPreview() {
val application = LocalContext.current.applicationContext as Application
MaterialTheme {
ManageCurrenciesScreen(
viewModel = ManageCurrenciesViewModel().apply {
viewModel = ManageCurrenciesViewModel(application).apply {
mainCurrencyName = "EUR"
currencies = listOf(
DBCurrency(1, 0, 0, "USD", 1.1, 0),
@@ -1,14 +1,24 @@
package net.helcel.cowspent.android.currencies
import android.app.Application
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.ViewModel
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.model.DBBill
import net.helcel.cowspent.model.DBCurrency
import net.helcel.cowspent.model.ProjectType
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.util.ICallback
class ManageCurrenciesViewModel : ViewModel() {
class ManageCurrenciesViewModel(application: Application) : AndroidViewModel(application) {
var projectId: Long = -1
var mainCurrencyName by mutableStateOf("")
var newCurrencyName by mutableStateOf("")
var newCurrencyRate by mutableStateOf("")
@@ -19,6 +29,105 @@ class ManageCurrenciesViewModel : ViewModel() {
var dialogState by mutableStateOf<DialogState?>(null)
private val db = CowspentSQLiteOpenHelper.getInstance(application)
fun loadCurrencies() {
if (projectId == -1L) return
viewModelScope.launch {
val project = withContext(Dispatchers.IO) { db.getProject(projectId) }
mainCurrencyName = project?.currencyName?.let { if (it == "null") "" else it } ?: ""
updateCurrenciesList()
}
}
suspend fun updateCurrenciesList() {
val currenciesDB = withContext(Dispatchers.IO) {
val list = db.getCurrenciesOfProjectWithState(projectId, DBBill.STATE_ADDED).toMutableList()
list.addAll(db.getCurrenciesOfProjectWithState(projectId, DBBill.STATE_EDITED))
list.addAll(db.getCurrenciesOfProjectWithState(projectId, DBBill.STATE_OK))
list
}
withContext(Dispatchers.Main) {
currencies = currenciesDB
}
}
fun saveMainCurrency(callback: ICallback) {
val newMainCurrencyName = mainCurrencyName
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.updateProject(
projId = projectId,
newCurrencyName = newMainCurrencyName
)
val project = db.getProject(projectId)
if (project != null) {
db.syncIfRemote(project)
if (project.type == ProjectType.COSPEND) {
withContext(Dispatchers.Main) {
if (!db.cowspentServerSyncHelper
.editRemoteProject(
projId = projectId,
newName = project.name,
newMainCurrencyName = newMainCurrencyName,
callback = callback
)
) {
// Handled by activity showing toast
}
}
}
}
}
}
}
fun addCurrency() {
val uiRate = try { newCurrencyRate.toDouble() } catch (_: Exception) { 0.0 }
val exchangeRate = if (uiRate != 0.0) 1.0 / uiRate else 0.0
val currencyName = newCurrencyName
val editingId = editingCurrencyId
viewModelScope.launch {
withContext(Dispatchers.IO) {
if (editingId != null) {
db.updateCurrency(editingId, currencyName, exchangeRate)
db.setCurrencyStateSync(editingId, DBBill.STATE_EDITED)
} else {
val newCurrency = DBCurrency(
0, 0, projectId,
currencyName, exchangeRate, DBBill.STATE_ADDED
)
db.addCurrencyAndSync(newCurrency)
}
}
cancelEditing()
updateCurrenciesList()
}
}
fun deleteCurrency(currencyId: Long) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
db.setCurrencyStateSync(currencyId, DBBill.STATE_DELETED)
}
updateCurrenciesList()
}
}
fun startEditing(currency: DBCurrency) {
editingCurrencyId = currency.id
newCurrencyName = currency.name ?: ""
val uiRate = if (currency.exchangeRate != 0.0) 1.0 / currency.exchangeRate else 0.0
newCurrencyRate = uiRate.toString()
}
fun cancelEditing() {
editingCurrencyId = null
newCurrencyName = ""
newCurrencyRate = ""
}
fun showDialog(
title: String? = null,
message: String? = null,
@@ -10,7 +10,7 @@ import androidx.appcompat.app.AppCompatActivity
import net.helcel.cowspent.theme.ThemeUtils
class LabelManagementActivity : AppCompatActivity() {
private val viewModel: LabelManagementViewModel by viewModels()
internal val viewModel: LabelManagementViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
@@ -107,7 +107,7 @@ fun LabelManagementScreenContent(
showEditDialog = true
}
}) {
Icon(Icons.Default.Add, contentDescription = null)
Icon(Icons.Default.Add, contentDescription = "Add Label")
}
}
) { padding ->
@@ -293,7 +293,7 @@ fun BillsListScreen(
isSearchExpanded = false
viewModel.searchQuery = ""
}) {
Icon(Icons.Default.Close, contentDescription = null, tint = MaterialTheme.colors.onPrimary)
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_clear_search), tint = MaterialTheme.colors.onPrimary)
}
}
)
@@ -322,13 +322,13 @@ fun BillsListScreen(
isSearchExpanded = false
viewModel.searchQuery = ""
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_close_search))
}
} else {
IconButton(onClick = {
scope.launch { scaffoldState.drawerState.open() }
}) {
Icon(Icons.Default.Menu, contentDescription = null)
Icon(Icons.Default.Menu, contentDescription = stringResource(R.string.action_open_menu))
}
}
},
@@ -343,7 +343,10 @@ fun BillsListScreen(
}
}
IconButton(onClick = { isSearchExpanded = true }) {
Icon(Icons.Default.Search, contentDescription = null)
Icon(
Icons.Default.Search,
contentDescription = stringResource(R.string.action_search)
)
}
}
},
@@ -18,6 +18,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.compose.material.icons.Icons
@@ -53,7 +54,9 @@ import net.helcel.cowspent.util.CospendClientUtil
import net.helcel.cowspent.util.ExportUtil
import net.helcel.cowspent.util.ICallback
import net.helcel.cowspent.util.IRefreshBillsListCallback
import net.helcel.cowspent.util.SyncSettings
import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.util.VersatileProjectSyncClient
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Calendar
@@ -63,13 +66,16 @@ class BillsListViewActivity :
AppCompatActivity(),
IRefreshBillsListCallback {
private val viewModel: BillsListViewModel by viewModels()
internal val viewModel: BillsListViewModel by viewModels()
companion object {
var DEBUG = false
private val TAG = BillsListViewActivity::class.java.simpleName
// Opening a project only re-syncs it if it has not synced within this window.
private const val SELECTED_PROJECT_SYNC_INTERVAL_MS = 60 * 1000L
private const val SAVED_STATE_NAVIGATION_SELECTION = "navigationSelection"
private const val SAVED_STATE_NAVIGATION_OPEN = "navigationOpen"
@@ -276,7 +282,7 @@ class BillsListViewActivity :
labelBillsLauncher.launch(LabelBillsActivity.createIntent(this, selectedProjectId))
}
},
onRefresh = { synchronize(true) }
onRefresh = { synchronize(SyncTrigger.MANUAL) }
)
}
}
@@ -330,10 +336,7 @@ class BillsListViewActivity :
}
viewModel.isRefreshing = false
if (db.cowspentServerSyncHelper.isSyncPossible) {
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
synchronize()
}
synchronize(SyncTrigger.APP_OPEN)
registerBroadcastReceiver()
updateAvatarInDrawer(CowspentServerSyncHelper.isNextcloudAccountConfigured(this))
@@ -347,6 +350,9 @@ class BillsListViewActivity :
} catch (_: RuntimeException) {
if (DEBUG) Log.d(TAG, "RECEIVER PROBLEM, let's ignore it...")
}
// The helper outlives the activity, and it only drains its pull callbacks when a task
// starts; drop ours so a paused activity is not retained by it.
db.cowspentServerSyncHelper.removeCallbackPull(syncCallBack)
isActivityVisible = false
}
@@ -373,7 +379,7 @@ class BillsListViewActivity :
navigationSelection = Category(null, null)
refreshLists(true)
synchronize()
synchronize(SyncTrigger.PROJECT_OPEN)
}
fun onManageProjectClick(projectId: Long) {
@@ -406,7 +412,7 @@ class BillsListViewActivity :
}
setupDrawerProjects()
refreshLists()
synchronize()
synchronize(SyncTrigger.PROJECT_OPEN)
val projectNameString = proj.name.ifEmpty { proj.remoteId }
showToast(this@BillsListViewActivity, getString(R.string.remove_project_confirmation, projectNameString))
}
@@ -422,19 +428,24 @@ class BillsListViewActivity :
val proj = withContext(Dispatchers.IO) { db.getProject(projectId) } ?: return@launch
val isArchiving = !proj.isArchived
val newArchivedTs = if (isArchiving) System.currentTimeMillis() / 1000 else 0L
val localArchivedTs = if (isArchiving) System.currentTimeMillis() / 1000 else 0L
val remoteArchivedTs = if (isArchiving) {
localArchivedTs
} else {
VersatileProjectSyncClient.REMOTE_ARCHIVED_TS_UNSET
}
withContext(Dispatchers.IO) {
db.updateProject(
projId = projectId,
newArchivedTs = newArchivedTs
newArchivedTs = localArchivedTs
)
}
if (!proj.isLocal) {
db.cowspentServerSyncHelper.editRemoteProject(
projId = projectId,
newArchivedTs = newArchivedTs,
newArchivedTs = remoteArchivedTs,
callback = object : ICallback {
override fun onFinish() {}
override fun onFinish(result: String, message: String) {
@@ -746,28 +757,60 @@ class BillsListViewActivity :
}
}
private fun synchronize(manual: Boolean = false) {
/**
* What prompted a sync. Only [APP_OPEN] may refresh the account and every project, and only
* then when the SyncOnOpen interval has elapsed; the other triggers touch the selected
* project alone.
*/
@VisibleForTesting
internal enum class SyncTrigger { APP_OPEN, PROJECT_OPEN, MANUAL }
@VisibleForTesting
internal fun synchronize(trigger: SyncTrigger) {
val preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext)
val offlineMode = preferences.getBoolean(getString(R.string.pref_key_offline_mode), false)
if (offlineMode && !manual) {
return
}
// isSyncPossible is networkConnected && !offlineMode, so offline mode is already covered
// here - including for a manual refresh, which cannot currently override it.
if (!db.cowspentServerSyncHelper.isSyncPossible) return
val selectedProjectId = preferences.getLong("selected_project", 0)
val now = System.currentTimeMillis()
// The account and all-projects refresh belongs to opening the app, throttled by the
// SyncOnOpen interval so that resuming within the interval does not repeat it.
val intervalMinutes = SyncSettings.intervalMinutes(applicationContext)
val lastAccountSync = preferences.getLong(getString(R.string.pref_key_last_account_sync_timestamp), 0L)
val accountSyncDue = trigger == SyncTrigger.APP_OPEN &&
now - lastAccountSync > intervalMinutes * 60 * 1000L
lifecycleScope.launch {
val remoteProjects = withContext(Dispatchers.IO) { db.projects }
.filter { !it.isLocal && !it.isArchived }
if (db.cowspentServerSyncHelper.isSyncPossible) {
viewModel.isRefreshing = true
val selectedProjectId = PreferenceManager.getDefaultSharedPreferences(applicationContext).getLong("selected_project", 0)
if (selectedProjectId != 0L) {
lifecycleScope.launch {
val proj = withContext(Dispatchers.IO) { db.getProject(selectedProjectId) }
if (proj != null && !proj.isLocal) {
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
db.cowspentServerSyncHelper.scheduleSync(false, selectedProjectId, manual)
} else viewModel.isRefreshing = false
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
val started = if (accountSyncDue) {
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
db.cowspentServerSyncHelper.runAccountProjectsSync()
}
} else viewModel.isRefreshing = false
if (CowspentServerSyncHelper.isNextcloudAccountConfigured(applicationContext)) {
db.cowspentServerSyncHelper.runAccountProjectsSync()
remoteProjects.count {
db.cowspentServerSyncHelper.scheduleSync(false, it, false) != null
}
} else {
val selectedProj = remoteProjects.find { it.id == selectedProjectId }
val lastSync = (selectedProj?.lastSyncedTimestamp ?: 0L) * 1000L
val due = trigger == SyncTrigger.MANUAL ||
now - lastSync > SELECTED_PROJECT_SYNC_INTERVAL_MS
if (selectedProj != null && due &&
db.cowspentServerSyncHelper.scheduleSync(
false, selectedProj, trigger == SyncTrigger.MANUAL
) != null
) 1 else 0
}
// Only a task that actually started reports back through syncCallBack, so clear
// the indicator here when none did - nothing else would.
if (started == 0) viewModel.isRefreshing = false
}
}
@@ -810,7 +853,7 @@ class BillsListViewActivity :
refreshLists()
}
MainConstants.BROADCAST_SYNC_PROJECT -> {
synchronize()
synchronize(SyncTrigger.PROJECT_OPEN)
}
MainConstants.BROADCAST_NETWORK_AVAILABLE -> {
}
@@ -838,7 +881,7 @@ class BillsListViewActivity :
refreshLists()
if (db.cowspentServerSyncHelper.isSyncPossible) {
db.cowspentServerSyncHelper.addCallbackPull(syncCallBack)
synchronize()
synchronize(SyncTrigger.PROJECT_OPEN)
}
}
}
@@ -31,7 +31,7 @@ import net.helcel.cowspent.android.project.ProjectImportHelper
class NewProjectActivity : AppCompatActivity() {
private val viewModel: NewProjectViewModel by viewModels()
internal val viewModel: NewProjectViewModel by viewModels()
private lateinit var db: CowspentSQLiteOpenHelper
override fun onCreate(savedInstanceState: Bundle?) {
@@ -3,8 +3,8 @@ package net.helcel.cowspent.android.project.edit
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
@@ -13,17 +13,16 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.helcel.cowspent.R
import net.helcel.cowspent.android.helper.showToast
import net.helcel.cowspent.android.main.MainConstants
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.theme.ThemeUtils
import net.helcel.cowspent.util.ICallback
import net.helcel.cowspent.util.SupportUtil
import net.helcel.cowspent.android.main.MainConstants
class EditProjectActivity : AppCompatActivity() {
private val viewModel: EditProjectViewModel by viewModels()
internal val viewModel: EditProjectViewModel by viewModels()
private lateinit var db: CowspentSQLiteOpenHelper
private lateinit var project: DBProject
@@ -56,29 +55,33 @@ class EditProjectActivity : AppCompatActivity() {
}
private fun onSave() {
when (viewModel.validate()) {
EditProjectViewModel.ValidationError.EMPTY_NAME -> {
showToast(this, getString(R.string.error_invalid_project_name), Toast.LENGTH_LONG)
return
}
EditProjectViewModel.ValidationError.INVALID_EMAIL -> {
showToast(this, getString(R.string.error_invalid_email), Toast.LENGTH_LONG)
return
}
null -> Unit
}
val changes = viewModel.changesFrom(project)
if (!changes.any) {
showToast(this, getString(R.string.project_edition_no_change), Toast.LENGTH_LONG)
return
}
val currentPwd = viewModel.password
val newPwd = viewModel.newPassword
val newName = viewModel.name
val newEmail = viewModel.email
if (newName.isEmpty()) {
showToast(this, getString(R.string.error_invalid_project_name), Toast.LENGTH_LONG)
return
}
if (newEmail.isNotEmpty() && !SupportUtil.isValidEmail(newEmail)) {
showToast(this, getString(R.string.error_invalid_email), Toast.LENGTH_LONG)
return
}
val nameChanged = newName != project.name
val emailChanged = newEmail != project.email
val pwdChanged = newPwd != project.password
val currentPwdChanged = currentPwd != project.password
if (!nameChanged && !emailChanged && !pwdChanged && !currentPwdChanged) {
showToast(this, getString(R.string.project_edition_no_change), Toast.LENGTH_LONG)
return
}
val nameChanged = changes.name
val emailChanged = changes.email
val pwdChanged = changes.newPassword
val currentPwdChanged = changes.currentPassword
if (project.isLocal) {
val targetPwd = if (pwdChanged) newPwd else currentPwd
@@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.ViewModel
import net.helcel.cowspent.android.helper.DialogState
import net.helcel.cowspent.model.DBProject
import net.helcel.cowspent.util.SupportUtil
class EditProjectViewModel : ViewModel() {
var name by mutableStateOf("")
@@ -17,6 +18,31 @@ class EditProjectViewModel : ViewModel() {
var dialogState by mutableStateOf<DialogState?>(null)
enum class ValidationError { EMPTY_NAME, INVALID_EMAIL }
fun validate(): ValidationError? = when {
name.isBlank() -> ValidationError.EMPTY_NAME
email.isNotEmpty() && !SupportUtil.isValidEmail(email) -> ValidationError.INVALID_EMAIL
else -> null
}
data class Changes(
val name: Boolean,
val email: Boolean,
val newPassword: Boolean,
val currentPassword: Boolean
) {
val any: Boolean get() = name || email || newPassword || currentPassword
}
fun changesFrom(project: DBProject) = Changes(
name = name != project.name,
// initFromProject maps a null or "null" email to "", so treat those as unchanged
email = email != project.email && !(email.isEmpty() && project.email == null),
newPassword = newPassword != project.password,
currentPassword = password != project.password
)
fun showDialog(
title: String? = null,
message: String? = null,
@@ -12,6 +12,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -100,6 +101,7 @@ fun MemberEditDialogContent(
.fillMaxWidth()
.clickable { isActivated = !isActivated }
.padding(vertical = 8.dp)
.testTag("member_activated_row")
) {
Icon(
imageVector = Icons.Default.Block,
@@ -22,7 +22,7 @@ import net.helcel.cowspent.theme.ThemeUtils
class MemberManagementActivity : AppCompatActivity() {
private val viewModel: MemberManagementViewModel by viewModels()
internal val viewModel: MemberManagementViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
@@ -20,6 +20,7 @@ import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.RadioButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Slider
import androidx.compose.material.Switch
import androidx.compose.material.SwitchDefaults
import androidx.compose.material.Text
@@ -49,6 +50,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
@@ -57,6 +59,7 @@ import net.helcel.cowspent.persistence.CowspentSQLiteOpenHelper
import net.helcel.cowspent.persistence.CowspentServerSyncHelper
import net.helcel.cowspent.util.ColorUtils
import net.helcel.cowspent.util.Cowspent
import net.helcel.cowspent.util.SyncSettings
@Composable
fun SettingsScreen(
@@ -81,7 +84,9 @@ fun SettingsScreen(
val keyOfflineMode = stringResource(R.string.pref_key_offline_mode)
val keyShowArchived = stringResource(R.string.pref_key_show_archived)
val keyBetaFeatures = stringResource(R.string.pref_key_beta_features)
val keyAutoSyncOnOpen = stringResource(R.string.pref_key_auto_sync_on_open)
val keyFillNewBillFromLast = stringResource(R.string.pref_key_fill_new_bill_from_last)
val keyLastAccountSync = stringResource(R.string.pref_key_last_account_sync_timestamp)
val isNextcloudConfigured = CowspentServerSyncHelper.isNextcloudAccountConfigured(context)
@@ -128,13 +133,25 @@ fun SettingsScreen(
mutableStateOf(sharedPreferences.getBoolean(keyFillNewBillFromLast, false))
}
val syncIntervals = SyncSettings.INTERVAL_CHOICES_MINUTES
val syncIntervalLabels = listOf(
stringResource(R.string.pref_value_sync_1m),
stringResource(R.string.pref_value_sync_10m),
stringResource(R.string.pref_value_sync_1h),
stringResource(R.string.pref_value_sync_1d)
)
var syncInterval by remember(keyAutoSyncOnOpen) {
mutableIntStateOf(SyncSettings.intervalMinutes(context))
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.action_settings)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
backgroundColor = MaterialTheme.colors.primary,
@@ -286,6 +303,24 @@ fun SettingsScreen(
}
}
)
SettingsSliderPreference(
title = stringResource(R.string.settings_auto_sync_on_open),
summary = stringResource(R.string.settings_auto_sync_on_open_summary),
icon = Icons.Default.Sync,
value = syncInterval,
values = syncIntervals,
labels = syncIntervalLabels,
onValueChange = { newInterval ->
// Slider reports every drag delta, not just the snapped steps.
if (newInterval != syncInterval) {
syncInterval = newInterval
sharedPreferences.edit {
putInt(keyAutoSyncOnOpen, newInterval)
}
}
}
)
}
SettingsPreference(
@@ -401,13 +436,7 @@ fun SettingsListPreference(
),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (key == value),
onClick = {
onValueChange(key)
showDialog = false
}
)
RadioButton(selected = (key == value), onClick = null)
Spacer(Modifier.width(8.dp))
Text(text = label)
}
@@ -470,6 +499,47 @@ fun SettingsColorPreference(
}
}
@Composable
fun SettingsSliderPreference(
title: String,
summary: String? = null,
icon: Any? = null,
value: Int,
values: List<Int>,
labels: List<String>,
onValueChange: (Int) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
SettingsIcon(icon)
Spacer(modifier = Modifier.width(32.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.subtitle1)
if (summary != null) {
Text(text = summary, style = MaterialTheme.typography.caption)
}
}
}
val currentIndex = values.indexOf(value).coerceAtLeast(0)
val steps = (values.size - 2).coerceAtLeast(0)
Column(modifier = Modifier.padding(start = 56.dp, top = 8.dp)) {
Slider(
value = currentIndex.toFloat(),
onValueChange = { onValueChange(values[it.roundToInt()]) },
valueRange = 0f..(values.size - 1).toFloat(),
steps = steps
)
Text(text = labels[currentIndex], style = MaterialTheme.typography.caption, fontWeight = FontWeight.Bold, color = MaterialTheme.colors.primary)
}
}
}
@Composable
fun SettingsIcon(icon: Any?) {
Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
@@ -47,7 +47,7 @@ class ProjectStatisticsActivity : AppCompatActivity() {
}
companion object {
private const val EXTRA_PROJECT_ID = "extra_project_id"
internal const val EXTRA_PROJECT_ID = "extra_project_id"
fun createIntent(context: Context, projectId: Long): Intent {
return Intent(context, ProjectStatisticsActivity::class.java).apply {
@@ -3,8 +3,8 @@ package net.helcel.cowspent.android.statistics
import android.app.DatePickerDialog
import android.content.Context
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
@@ -285,9 +285,18 @@ fun ProjectStatisticsTable(
Spacer(modifier = Modifier.height(4.dp))
LazyColumn(modifier = Modifier.weight(1f)) {
items(stats.memberStats) { m ->
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
) {
stats.memberStats.forEach { m ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(m.name, modifier = Modifier.weight(2f), color = MaterialTheme.colors.onSurface, fontWeight = FontWeight.Medium)
Text(
@@ -7,6 +7,7 @@ import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.text.TextUtils
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import androidx.preference.PreferenceManager
import net.helcel.cowspent.R
@@ -390,8 +391,8 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
fun getActivatedMembersOfProject(projId: Long): List<DBMember> {
return getMembersCustom(
"$key_projectid = ? AND $key_activated = 1",
arrayOf(projId.toString()),
"$key_projectid = ? AND $key_activated = 1 AND $key_state != ?",
arrayOf(projId.toString(), DBBill.STATE_DELETED.toString()),
"$key_name ASC"
)
}
@@ -570,70 +571,54 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
fun searchBills(query: CharSequence?, projectId: Long): List<DBBill> {
val andWhere: MutableList<String> = ArrayList()
val args: MutableList<String> = ArrayList()
andWhere.add("($key_projectid = $projectId)")
andWhere.add("($key_projectid = ?)")
args.add(projectId.toString())
andWhere.add("($key_state != ${DBBill.STATE_DELETED})")
if (query != null) {
args.add("%$query%")
var whereStr = "($key_what LIKE ?"
if (SupportUtil.isDouble(query.toString())) {
whereStr += " OR ($key_amount <= (? + 10) AND $key_amount >= (? - 10))"
args.add(query.toString())
args.add(query.toString())
val terms = query?.toString()?.split("\\s+".toRegex())?.filter { it.isNotEmpty() }.orEmpty()
if (terms.isNotEmpty()) {
val memberIdsByName = getMembersOfProject(projectId, null)
.associateBy({ it.name.lowercase(Locale.ROOT) }, { it.id })
// Every clause appends its arguments as it is built, so args stay in the same order
// as the placeholders they bind to.
for (term in terms) {
andWhere.add(memberClause(term, memberIdsByName, args) ?: textClause(term, args))
}
val members = getMembersOfProject(projectId, null)
val memberNames: MutableList<String> = ArrayList()
val memberIds: MutableList<Long> = ArrayList()
for (m in members) {
memberNames.add(m.name.lowercase(Locale.ROOT))
memberIds.add(m.id)
}
val queryStr = query.toString()
val words = queryStr.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
var nameSql = ""
for (word in words) {
if (word.startsWith("+")) {
val nameQuery = word.replace("^\\+".toRegex(), "")
val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT))
if (memberIndex != -1) {
val searchMemberId = memberIds[memberIndex]
nameSql += "($key_payer_id=?) AND "
args.add(searchMemberId.toString())
}
}
if (word.startsWith("-")) {
val nameQuery = word.replace("^-".toRegex(), "")
val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT))
if (memberIndex != -1) {
val searchMemberId = memberIds[memberIndex]
val joinOwer = "select $table_bills.$key_id from $table_bills inner join $table_billowers " +
"where $key_member_id=? and $table_bills.$key_id=$table_billowers.$key_billId"
nameSql += "($key_id IN ($joinOwer)) AND "
args.add(searchMemberId.toString())
}
}
if (word.startsWith("@")) {
val nameQuery = word.replace("^@".toRegex(), "")
val memberIndex = memberNames.indexOf(nameQuery.lowercase(Locale.ROOT))
if (memberIndex != -1) {
val searchMemberId = memberIds[memberIndex]
nameSql += "( ($key_payer_id=?) OR "
args.add(searchMemberId.toString())
val joinOwer = "select $table_bills.$key_id from $table_bills inner join $table_billowers " +
"where $key_member_id=? and $table_bills.$key_id=$table_billowers.$key_billId"
nameSql += "($key_id IN ($joinOwer)) ) AND "
args.add(searchMemberId.toString())
}
}
}
if (nameSql != "") {
nameSql = nameSql.replace(" AND $".toRegex(), "")
whereStr += " OR ($nameSql)"
}
whereStr += ")"
andWhere.add(whereStr)
}
val order = "$key_timestamp DESC"
return getBillsCustom(TextUtils.join(" AND ", andWhere), args.toTypedArray(), order)
return getBillsCustom(TextUtils.join(" AND ", andWhere), args.toTypedArray(), "$key_timestamp DESC")
}
/** Clause for a `+name`/`-name`/`@name` term, or null when the term names no member. */
private fun memberClause(term: String, memberIdsByName: Map<String, Long>, args: MutableList<String>): String? {
val prefix = term.first()
if (prefix != '+' && prefix != '-' && prefix != '@') return null
val memberId = memberIdsByName[term.substring(1).lowercase(Locale.ROOT)] ?: return null
val owedByMember = "SELECT $table_bills.$key_id FROM $table_bills INNER JOIN $table_billowers " +
"WHERE $key_member_id = ? AND $table_bills.$key_id = $table_billowers.$key_billId"
args.add(memberId.toString())
return when (prefix) {
'+' -> "($key_payer_id = ?)"
'-' -> "($key_id IN ($owedByMember))"
else -> {
args.add(memberId.toString())
"(($key_payer_id = ?) OR ($key_id IN ($owedByMember)))"
}
}
}
/** Clause matching a free text term against the description, the comment and the amount. */
private fun textClause(term: String, args: MutableList<String>): String {
val needle = "%" + term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
args.add(needle)
args.add(needle)
var clause = "(($key_what LIKE ? ESCAPE '\\') OR ($key_comment LIKE ? ESCAPE '\\')"
if (SupportUtil.isDouble(term)) {
clause += " OR ($key_amount <= (? + $AMOUNT_SEARCH_TOLERANCE) AND " +
"$key_amount >= (? - $AMOUNT_SEARCH_TOLERANCE))"
args.add(term)
args.add(term)
}
return "$clause)"
}
@WorkerThread
@@ -1240,6 +1225,9 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
)
private const val default_order = "$key_id DESC"
/** Half width of the window a numeric search term matches, in project currency. */
private const val AMOUNT_SEARCH_TOLERANCE = 10
@Volatile
private var instance: CowspentSQLiteOpenHelper? = null
@@ -1249,5 +1237,15 @@ class CowspentSQLiteOpenHelper private constructor(val context: Context) :
instance ?: CowspentSQLiteOpenHelper(context.applicationContext).also { instance = it }
}
}
@VisibleForTesting
fun setInstance(helper: CowspentSQLiteOpenHelper?) {
instance = helper
}
@VisibleForTesting
fun resetInstance() {
instance = null
}
}
}
@@ -8,6 +8,7 @@ import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.os.IBinder
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.core.content.edit
import androidx.core.graphics.toColorInt
import androidx.preference.PreferenceManager
@@ -90,14 +91,38 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
fun addCallbackPull(callback: ICallback) {
callbacksPull.add(callback)
// Callers register on every resume but the list is only drained when a task actually
// starts, so refuse duplicates rather than letting the same callback pile up.
if (!callbacksPull.contains(callback)) {
callbacksPull.add(callback)
}
}
fun scheduleSync(onlyLocalChanges: Boolean, projId: Long, forceFullSync: Boolean = false): SyncTask? {
fun removeCallbackPull(callback: ICallback) {
callbacksPull.remove(callback)
}
fun scheduleSync(onlyLocalChanges: Boolean, projId: Long, forceFullSync: Boolean = false): SyncTask? =
scheduleSync(onlyLocalChanges, projId, forceFullSync) { dbHelper.getProject(projId) }
/**
* Overload for callers that already hold the project. Resolving one by id costs a query plus
* a blocking DataStore read and an AEAD decrypt in getProjectFromCursor, which adds up when
* scheduling a sync for every project at app open.
*/
fun scheduleSync(onlyLocalChanges: Boolean, project: DBProject, forceFullSync: Boolean = false): SyncTask? =
scheduleSync(onlyLocalChanges, project.id, forceFullSync) { project }
private fun scheduleSync(
onlyLocalChanges: Boolean,
projId: Long,
forceFullSync: Boolean,
resolveProject: () -> DBProject?
): SyncTask? {
Log.d(TAG, "Sync requested (${if (onlyLocalChanges) "onlyLocalChanges" else "full"}; ${if (syncActive) "sync active" else "sync NOT active"}; forceFullSync=$forceFullSync) ...")
updateNetworkStatus()
if (isSyncPossible && (!syncActive || onlyLocalChanges)) {
val project = dbHelper.getProject(projId)
val project = resolveProject()
if (project != null) {
Log.d(TAG, "... starting now")
val syncTask = SyncTask(onlyLocalChanges, project, forceFullSync)
@@ -1027,8 +1052,21 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
var status = LoginStatus.OK
try {
// Pass current project values if the new ones are null to ensure a complete project object is sent to the server
val currentProj = project!!
val finalName = (newName ?: currentProj.name).let { if (it.isBlank() || it == "null") currentProj.remoteId else it }
// Stay null when the project has no main currency, so the PUT omits currencyName
// instead of silently setting the server-side currency.
val finalCurrency = (newMainCurrencyName ?: currentProj.currencyName)
?.takeUnless { it.isBlank() || it == "null" }
val response = client!!.editRemoteProject(
project!!, newName, newEmail, newPassword, newMainCurrencyName, newArchivedTs
currentProj,
finalName,
newEmail ?: currentProj.email,
newPassword,
finalCurrency,
newArchivedTs
)
if (BillsListViewActivity.DEBUG) {
Log.i(TAG, "RESPONSE edit remote project : ${response.stringContent}")
@@ -1418,6 +1456,11 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
}
private fun onPostExecute(status: LoginStatus) {
if (status == LoginStatus.OK) {
preferences.edit {
putLong(appContext.getString(R.string.pref_key_last_account_sync_timestamp), System.currentTimeMillis())
}
}
if (status != LoginStatus.OK) {
var errorString = appContext.getString(R.string.error_sync, appContext.getString(status.str)) + "\n\n"
for (errorMessage in errorMessages) {
@@ -1673,6 +1716,12 @@ class CowspentServerSyncHelper private constructor(private val dbHelper: Cowspen
return instance!!
}
@VisibleForTesting
fun resetInstance() {
instance = null
projectIdsToSync.clear()
}
fun isNextcloudAccountConfigured(context: Context): Boolean {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
return !preferences.getString(AccountActivity.SETTINGS_URL, AccountActivity.DEFAULT_SETTINGS).isNullOrEmpty() ||
@@ -86,7 +86,7 @@ class NextcloudClient(
}
@Throws(TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
private fun requestServerWithSSO(
internal fun requestServerWithSSO(
nextcloudAPI: NextcloudAPI,
target: String,
method: String,
@@ -141,7 +141,7 @@ class NextcloudClient(
}
@Throws(TokenMismatchException::class, NextcloudHttpRequestFailedException::class)
private fun imageRequestServerWithSSO(
internal fun imageRequestServerWithSSO(
nextcloudAPI: NextcloudAPI,
target: String,
method: String,
@@ -182,7 +182,7 @@ class NextcloudClient(
}
@Throws(IOException::class, NextcloudHttpRequestFailedException::class)
private fun requestServer(
internal fun requestServer(
target: String,
method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData {
@@ -248,7 +248,7 @@ class NextcloudClient(
}
@Throws(IOException::class, NextcloudHttpRequestFailedException::class)
private fun imageRequestServer(
internal fun imageRequestServer(
target: String,
method: String, params: JSONObject?, lastETag: String?, needLogin: Boolean, isOCSRequest: Boolean
): VersatileProjectSyncClient.ResponseData {
@@ -158,10 +158,18 @@ class VersatileProjectSyncClient(
paramKeys.add("password")
paramValues.add(newPassword)
}
if (newEmail != null) {
paramKeys.add("contact_email")
paramValues.add(newEmail)
}
if (newMainCurrencyName != null) {
paramKeys.add("currencyName")
paramValues.add(newMainCurrencyName)
}
if (newArchivedTs != null) {
paramKeys.add("archivedTs")
paramValues.add(newArchivedTs.toString())
}
}
if (canAccessProjectWithNCLogin(project)) {
username = this.username
@@ -1520,5 +1528,8 @@ class VersatileProjectSyncClient(
const val METHOD_POST = "POST"
const val METHOD_PUT = "PUT"
const val METHOD_DELETE = "DELETE"
const val REMOTE_ARCHIVED_TS_NOW = 0L
const val REMOTE_ARCHIVED_TS_UNSET = -1L
}
}
+11
View File
@@ -10,6 +10,9 @@
<string name="action_edit">Edit</string>
<string name="action_share">Share</string>
<string name="action_search">Search</string>
<string name="action_open_menu">Open menu</string>
<string name="action_close_search">Close search</string>
<string name="action_clear_search">Clear search</string>
<string name="action_delete">Delete</string>
<string name="action_archive">Archive</string>
<string name="action_unarchive">Unarchive</string>
@@ -139,6 +142,12 @@
<string name="settings_beta_features_summary">Enable experimental features. Use at your own risk.</string>
<string name="settings_fill_new_bill_from_last">Auto-fill from last bill</string>
<string name="settings_fill_new_bill_from_last_summary">Pre-fill payer, category, mode and owers from the last bill created in the project.</string>
<string name="settings_auto_sync_on_open">Sync interval</string>
<string name="settings_auto_sync_on_open_summary">How often to refresh the account and all projects when opening the app.</string>
<string name="pref_value_sync_1m">1 minute</string>
<string name="pref_value_sync_10m">10 minutes</string>
<string name="pref_value_sync_1h">1 hour</string>
<string name="pref_value_sync_1d">1 day</string>
<string name="settings_url_warn_http">WARNING: "http" is unsafe. Use "https".</string>
<string name="settings_colorpicker_title">Choose Color</string>
@@ -160,6 +169,8 @@
<string name="pref_key_offline_mode" translatable="false">offlineMode</string>
<string name="pref_key_show_archived" translatable="false">showArchived</string>
<string name="pref_key_beta_features" translatable="false">betaFeatures</string>
<string name="pref_key_auto_sync_on_open" translatable="false">autoSyncOnOpen</string>
<string name="pref_key_last_account_sync_timestamp" translatable="false">lastAccountSyncTimestamp</string>
<string name="pref_key_fill_new_bill_from_last" translatable="false">fillNewBillFromLast</string>
<string name="pref_value_night_mode_no" translatable="false">1</string>
<string name="pref_value_night_mode_yes" translatable="false">2</string>
+1 -1
View File
@@ -19,7 +19,7 @@ org.gradle.dependency.verification.console=verbose
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
org.gradle.parallel=true
android.useAndroidX=true
android.nonTransitiveRClass=false
org.gradle.warning.mode=all